Spawn
playmakespawnjam
⌘K
visitorclaim namesign in
sign up
playmake

spawn / about

what we're building

everything we ship, and the reasons.

what spawn is

  • your place to make games with friends
  • describe any game you can imagine. savi builds it with you
  • powered by the best AI models, free
  • publish in one click. real players in minutes
  • everything you make is multiplayer from the start

pinned

start here

what spawn is

the whole idea in one read: what you make, who Savi is, how building feels.

pinnedread →

faq

frequently asked questions

quick answers: making games with Savi, multiplayer, publishing, earning, fixes.

pinnedread →

the bet

the spawn bet

why games, why now, and why an engine built for AI from day one.

pinnedread →

updates

engine v5.2

Lume

The renderer, rebuilt in-house: your worlds look the same and run on a fraction of the GPU — faster loads, cooler machines, longer battery.

1mo agoread →
v5.2.34Six cures: deleted objects can never pop back after the save confirms, players' script-driven saves stop dying silently in relay rooms, tweaking one knob on a custom material no longer wipes the material, group placed pieces without anything moving (keepWorld), roads can vary width along the route, and old room-host images can't silently wedge new engine sessions.2d agov5.2.33Fifteen cures in one: vehicles stop reloading scrambled, drags stop teleporting to the last save, Ctrl-Z survives reloads in multiplayer, half-joined sessions stop ghost-simulating live players, stale tabs can't cross players into your editing room, middle-click is reliable, grass reads green from both sides, things stay the right size entering new areas, game UI can save files, scripted terrain edits stop wiping paint, and slow machines stop tearing themselves down to 'recover' from their own lag.3d agov5.2.32Ten cures in one: meshes reworked behind a hidden tab reappear, wire-spawned objects land for every joiner, custom-cursor games click again on desktop, multi-area games stop losing areas, shared UI library edits hot-reload, phantom version churn ends, momentary server hiccups stop reading as dead models, static-model shadows return on iPhone/iPad, big worlds stream in smoother, and creators can cap the frame rate.4d agov5.2.31Sixteen fixes in one: desktop clicks work again in click-driven games, big built-out worlds run much smoother while you build, scripted movers stop freezing in long-running multiplayer rooms, long multiplayer music stops dying after a second, riders stay seated instead of running in place, rotations stick while you drag, 2D characters stop flashing white between animations, spawn-storms no longer flood the room, and pause menus can catch the first Esc.4d agov5.2.30Thirteen fixes in one: quiet saves for multi-area games, touch-ready script buttons, taps that land on bridges and platforms, terrain edits that persist, smoother joins on busy rooms, no tab-return flashes, and steadier building while wisps work.6d agov5.2.29The 08-17 walk train, re-cut: everything since 5.2.26 in one cut — the join-wall relief, the confession family, the ghost cures, exact math — plus the walk-found voxel sim fix.1w agov5.2.26Terrain edits now sync reliably in always-on worlds. Players who rejoin get the terrain exactly as it was edited — edits made by scripts or by players who have since left no longer silently vanish on rejoin. And freshly edited chunks rebuild ahead of background work, so digs and builds turn solid and visible faster even while the world is busy streaming.1w agov5.2.25Big worlds with lots of detailed scenery run noticeably smoother while you walk around — the engine now remembers collision shapes it has already built instead of rebuilding them from scratch every time objects stream in or toggle their physics.1w agov5.2.24Players 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 new getPlayerPlace(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.1w agov5.2.23Savi now files the features you keep wishing for with the Spawn team — a dream that comes back session after session gets written down once, in your words, instead of staying her private workaround.1w agov5.2.21When a database operation takes a moment longer than a script run allows, Savi now gets a plain explanation — the database is answering, nothing hung, just re-run — instead of a scary "hung await" error that used to send her hunting for a bug that wasn't there.2w agov5.2.20Cleaned up engine diagnostic noise that every game session was quietly emitting — no visible change in your game, slightly less background chatter from the engine.2w agov5.2.19Small windows render sharp: shrinking the game window no longer melts distant detail into mush — thin lines, far-off structures, and in-world text stay readable at small window sizes, because the engine now refuses to drop its internal rendering resolution below a legibility floor on ordinary screens.2w agov5.2.18Sounds can now stick to the thing that made them: playSound(clip, { position, follow: true }) keeps a sound on a moving object instead of leaving it behind at the spot it started — your own footsteps, engine hums, and whooshes stay with you instead of trailing behind as you run.2w agov5.2.15You can now put the game's "ear" anywhere — pin it to a ring-side mic, a security camera, or a moving NPC, and all spatial sound pans and fades from there instead of your camera. One place setting, and clearing it puts the ear right back.3w agov5.2.14Safari no longer eats all your memory while a game sits waiting for its first click — the bug that could freeze a whole laptop on a quiet world is gone.3w agov5.2.13Cameras are yours now, all the way down. Savi writes your camera as a script she can read and edit — every zoom, follow, and framing choice is a line she can change — instead of flipping switches on fixed camera presets. Existing games keep playing exactly as they are; when a world upgrades to this version, Savi rewrites its camera as a script with the same feel.3w agov5.2.12Buttons 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.3w agov5.2.11Savi can now measure your world instead of eyeballing it: asking for an object's bounds answers the real loaded size of models (stacking, fitting, and aligning become arithmetic), and while a model is still downloading she gets an honestly-flagged placeholder or a clear "still loading" note instead of a silent nothing — so things land ON tables, not 0.18m into them.3w agov5.2.10Worn gear welds on, moving platforms carry you, cooking models show their art3w agov5.2.9Saves stop storming, cities stop blinking, Safari plays3w agov5.2.8Power lines plant their poles again3w agov5.2.6Fixed a failure mode where a player could sink into (or bob on) one patch of ground forever with no explanation: if the game client repeatedly fails to build the ground under a player, it now stops silently retrying, tells Savi exactly which chunk failed and why, and shows the affected player an honest "reload to rejoin" message instead of leaving them stuck.4w agov5.2.5Physics got a big quality pass in worlds using the new physics engine — cleaner collisions, fast objects stop tunneling, vehicles handle better, and things settle instead of jittering. Busy worlds also run noticeably cheaper.4w agov5.2.4Fixed top-down (2d-top) characters colliding ~1m south of where their sprite stands: walking into a wall from the north stopped short, from the south overlapped, and the debug collider circle drew below the character's feet. Colliders now sit exactly at the sprite's feet regardless of the object's rotation.1mo agov5.2.3Fixed god mode getting stuck at an invisible floor in games with terrain turned off — you can now fly down to builds below height 0 instead of being silently held at the old ground level.1mo agov5.2.2Fixed sprites and objects that had stopped moving randomly lurching a few pixels out and back in quiet scenes (most visible on parked critters in 2D games). Resting entities now hold their position exactly until they actually move again.1mo agov5.2.1Growing black patches are gone. On some machines, dark blotches could appear on screen (worst at night) and spread until they swallowed the whole view — reloading didn't help. Fixed at the source, for good.1mo ago

engine v5.1

Connection

Your game listens to you first — movement and actions respond the instant you press them, and multiplayer holds steady through the busy moments. A full rebuild of how players stay in sync, plus dozens of touches to sound, saving, and Savi's tools along the way.

2mo agoread →
v5.1.13When your game's music code hits a wrong note — an unknown drum name, a bad pattern — Savi actually hears about it now, with a helpful nudge toward the closest real instrument. And "tabla" just works.1mo agov5.1.12Fixed 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.1mo agov5.1.11Savi can now invent animations for your characters — a swim stroke, a dance, a wing-flap — by writing them as motion code, no animation files needed. They play (and blend) just like regular animations, and edits take effect at the next loop so nothing pops mid-motion.1mo agov5.1.102D games got a big placement upgrade: backgrounds and distant scenery can now be glued to the camera, so they always fill the screen — no more backdrops drifting away from the view, floating set pieces, or stretched skies. Repeating strips tile seamlessly across the whole view, parallax layers slide at their own speeds, and everything holds up at any screen shape from tall phones to ultrawide.1mo agov5.1.9⚠️ If your world suddenly looks different after this update: your custom look script is running again. It was silently crashing on the last few engine versions (since around 5.1.5), so your world was rendering with no look applied at all. The crash is fixed and your authored look applies again — that new appearance is your own look script working. If it feels off (too dark, too bright, too saturated), ask Savi to retune the exposure — don't delete the look.1mo agov5.1.8Side-scroller levels with visual-only heightmaps no longer get fake ground snaps or repeated collider rebuild warnings.1mo agov5.1.6Large terrain worlds spend much less CPU maintaining stable physics colliders.1mo agov5.1.5Bloom is now much more performant.2mo agov5.1.4Performance improvements for spoin animations also make games run faster.2mo agov5.1.3Savi can now notice and help fix visual effects, interface panels, sounds, and other game behavior that fails only on a player's device.2mo agov5.1.2Scripted materials no longer lose their shading after reloading a game.2mo agov5.1.1Room-based multiplayer games now recognize the correct room on every player, so shared lobby counts, match progression, and room controls keep working.2mo ago

engine v5.0

For Real

A million particles, real skies, enemies with brains — the world got real, and Savi can build all of it. The biggest engine release yet.

2mo agoread →
v5.0.15Multiple moving shadow-casting lights now all keep their shadows — previously only one moving light at a time could.2mo agov5.0.14Colored and projector light shadows now work for as many lights as your scene needs — they previously stopped working past one or two on many graphics cards.2mo agov5.0.13Boardwalks built down slopes with tight switchback corners now render coherently — no more railing bars shooting past hairpin turns or flickering planks on long straight runs.2mo agov5.0.12Sounds 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.2mo agov5.0.11Savi's script tools work again in solo games with mouse-look cameras — a 5.0.10 regression broke her ability to run scripts there.2mo agov5.0.10Fixed a bug where talking to Savi with your voice could get stuck in a silent retry loop if the microphone pipeline failed to start — it would quietly hammer away forever and flood our error logs while voice just didn't work. Now it tries a few times, tells you plainly that voice is off for this session, and typing keeps working (reloading the page may bring voice back). Quick tap-and-re-hold on the mic can no longer be mistaken for a real failure.2mo agov5.0.93D models are now automatically optimized for better performance, with additional simpler versions used as they move farther away.2mo agov5.0.8Shadows now fit every graphics card the same way: machines with tighter limits (many Macs) keep full shadow quality at every tier instead of dropping to a single sun shadow. Secondary sun shadows in big worlds render slightly smaller on low and medium quality.2mo agov5.0.7Games now automatically turn down expensive graphics on hardware that can't keep up — effects cadence first, then shadow distances, then bloom and resolution, and only on the struggling device. Same game, same content, smooth on more machines; the device remembers where it landed so the next session starts there.2mo agov5.0.6Screen 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.2mo agov5.0.5Savi stops nagging about performance: at most one heads-up per hour, only for games genuinely slow for minutes on end, and she never changes your game over it without your say-so.2mo agov5.0.4Skies now cost almost nothing, multiplayer sockets stop dropping when dev and live rooms share a server, and mobile play opens the moment a game declares it.2mo agov5.0.3The launch-week patch train: solo worlds keep every area, portals stop flinging you home, joins always retry, first effects play clean, and the engine heals broken ground and graphics stalls on its own.2mo agov5.0.2The editor cursor finally feels like your real mouse — pixel-for-pixel, no more laggy ghost trailing your hand in god mode2mo agov5.0.1Scripts that animate with Math.sin, ease with Math.exp, or steer with Math.atan2 now compute the exact same bits on the server and on every player's machine. Before, different browsers' built-in math disagreed by invisible amounts that physics contact could amplify into visible corrections — cars and props you drove through obstacles could stutter as the server "corrected" the client mid-drive. Math results may shift by amounts far below anything perceivable (the same on every machine), in exchange for driving, easing, and orbiting code that no longer fights the network.2mo ago

engine v4.6

Atelier

A real workshop for god mode — tabbed tools, undo/redo, and richer material looks.

2mo agoread →

engine v4.5

Surface Tension

Water you can swim in, and shapes that look like the real thing.

3mo agoread →
v4.5.2Animated models always play something — falls back to an available animation instead of standing still.2mo agov4.5.1Ponds run much smoother without changing how they look.3mo ago

engine v4.4

Solid

Things stack right, worlds load clean, and clicks land where you point.

3mo agoread →
v4.4.3Fixed some characters appearing too small.3mo agov4.4.2Build bigger worlds with lots of places — areas no one is in quietly sleep so the game stays smooth, and snap back to life the moment someone walks in. Your spawn area is always ready.3mo agov4.4.1Savi places things on top of anything now — models, primitives, sprites, custom geometry, or text, she knows the size and lands the placement first try.3mo ago

engine v4.3

Groovy

Savi is your DJ now. God mode, movement, and controls all feel right

3mo agoread →
v4.3.1Walls and floors are back in 3D Rooms games. Players land cleanly, walls stop the player, and skeletons stand on the floor instead of falling into it.3mo ago

engine v4.2

Continuum

Saved games, smoother animation, sharper aim, and a basket of polish fixes you'll feel right away.

3mo agoread →

engine v4.1

Foundations

First big engine update. Faster, smoother, smarter — and a small mountain of fixes.

3mo agoread →

engine v0.1

Genesis

Initial versioned engine release.

3mo agoread →

the full text

Every post and engine release on this page, in full, so one read is the whole picture.

the bet

the spawn bet

my girlfriend has been describing the same game to me for years — a roguelike where you forage for mushrooms, cook soup, and build a town around the soup — and she's had no way to build it. she's not lazy and she's not dumb. she just isn't going to spend four years learning c# to chase one idea. that's the situation the entire medium is in. the great games we have were made by the tiny number of people who happened to combine an idea with the patience to sit in a toolchain for a decade, and almost every other idea died before it got near a screen. stardew valley took one person four years alone in a basement. that's not a romantic story about craft, it's a story about a filter — a filter that screens out everyone whose patience or circumstance or wrist tendons gave out before year four. the games we have are the survivors of it. the games we don't have are everyone else's.

the obvious move at this point in history is to glue a chatbot onto unity and call it the future of game development. several companies have done this. it doesn't work for the same reason strapping an llm to microsoft word didn't replace authors — the bottleneck was never typing speed. unity's editor was built for the survivors of the filter, and any ai you bolt onto it inherits every assumption baked in: that you know what a prefab is, that you'll patiently rebuild your scene graph when the netcode breaks, that of course you'll learn the shader pipeline because eventually you'll need to. my girlfriend is not going to learn what a prefab is. she shouldn't have to.

you say "a forest where the trees argue." three seconds later you're standing in one. savi tells you she gave the oak the most opinions because she thought you'd like that. you ask her to make the birch louder. the birch starts insulting the oak. your friend joins through a link, lands next to you in the same forest, and starts laughing at the birch. you're standing in something that didn't exist forty seconds ago.

two things make that possible. every message to savi is free — not because she's cheap, she isn't, but because making creators count tokens before they speak would kill the only loop that actually produces good games. the only way to find a game is to riff for an hour, hate most of it, and stumble into the version that's alive. you can't do that if you're metering yourself. we eat the cost. and every game is multiplayer from the first message, because the runtime is multiplayer underneath and turning it off would have been the harder thing to do. these aren't features we tacked on — they're what you get when the foundation is the right shape, and together they completely change what creation feels like: you say something, savi answers, your friend is already standing in it, and you change it again before any of you stop laughing.

what happens after this is youtube. not the cringe internet-pundit version of that comparison, the actual one. youtube didn't make everyone a great filmmaker — it made trying cheap enough that millions of people tried, and most were bad for years, and a meaningful fraction of those people, by watching each other and copying each other and getting torn apart in comments and trying again, became better filmmakers than the film-school graduates whose tuition could have paid for an apartment. the medium taught itself. there was no curriculum. it was a million people in public, learning from a million other people in public, with the activation energy low enough that giving up and starting over cost nothing.

spawn has to be that for games. there's no other way the medium gets a hundred times bigger, which it has to. right now there are about two thousand games shipped on steam in a year. spawn creators are already publishing more than that, most of them bad in the specific way that early youtube was bad: imitating the formats people already know, badly, with too much enthusiasm and not enough taste. that's correct. that's the early phase. taste is a community phenomenon — it doesn't get installed in anyone before they start, it grows from looking at what someone else made and quietly deciding what you'd have done differently.

what we want at the other end of this isn't infinite slop. it isn't a feed of disposable games engineered to keep someone tapping. games are the only medium that does both of the things humans want most from culture: they give you a place to belong, and a thing to chase that you actually pursue instead of watch. when you lead a guild in world of warcraft, the leadership is real even though the dragons aren't. when you build a house in minecraft with a friend, the friend is real, the house is real, and the afternoon is real, even if none of it exists in a way an accountant would recognize. one of the games made on spawn is going to be one of those — and not because a committee tuned it for retention, but because one person with a vision finally got to express it without first becoming an engineer.

my girlfriend is going to build her game. the mushrooms will be the exact right shade of brown. the soup will simmer convincingly. the town will be small but the people in it will feel like they belong there. a few hundred people will play it, a chunk of them strangers, and someone she's never met will message her at 1am to say she's still foraging. that is the medium working. the next person who makes a game on spawn will have seen hers and learned something from it. the person after that will have seen both. ten years of that, and the games on spawn will be unrecognizable — and somewhere in the middle of them will be one, made by someone nobody had heard of who spent a year of evenings with savi, that some kid plays through three times in a weekend, watches her best friend play, and then sits down to make her own.

start here

what spawn is

you open spawn and you're standing in an empty world — flat ground, a pale sky, room to walk. you tell savi you want a snowy village at dusk. she builds it: hills, cottages, snow starting to fall. she also puts a chapel at the edge — she thought every village like this should have one. you weren't going to put a chapel there. you ask her to make it the only building still lit. she pulls the light from the rest of the village. that's the whole loop: you say a thing, she says one back, and what you end up with is something neither of you would have made alone.

spawn is a place to make multiplayer games by talking — side-scrolling, top-down, 3D, whatever the game wants to be. you describe what you want; the world appears around you, fully made. you bring the idea. nothing else is required. creating is free, and stays free however many times you change your mind.

savi

savi is who you build with. she's a game designer with her own taste, her own ideas, and an obvious, infectious love of the work. she's in the world with you the whole time: send a message and she's looking at exactly what you're looking at — the same hill, the same jump that feels wrong, the same enemy clipping through the floor.

ask for a castle on that hill and she builds it while you watch. tell her it should feel creepier and she pulls the light down, rolls in fog, and slows the music — she doesn't stop to ask which color. she'd rather make a real call and hear you disagree than hand you a blank to fill in. you answer it, she answers back, the game moves between you. that's the collaboration.

in most tools, the idea is the easy part — everything else is on you: sourcing assets, wiring multiplayer, running servers, writing every line of the game itself. savi makes all of that part of the conversation. you describe what you want; she shapes the terrain, lights the dusk so it looks like dusk, scores the boss fight, gives the NPCs voices she invents on the spot — with her own taste in every choice. you react, she adjusts, the version that lands is one neither of you would have made alone. underneath the whole thing, multiplayer is already running. you bring the idea. she brings everything that used to require a team. the game gets made between you.

the moment you describe a game, savi takes it on like it's hers too. when the safe version of an idea is smothering the interesting one, she says so, and shows you the braver cut. when something finally lands, she's the first to see it and the loudest about it. and the whole time, she's listening for the game under the game — the thing you put in without noticing, the real reason you wanted to build this — and when she catches it, she builds toward it.

you don't have to know how any of this is done. you have to know what you want — and when you don't, she shows you something, you react, and you find it together.

building is a conversation

think of it as shaping clay, not placing an order.

the worst way to use spawn is to write one enormous prompt and wait for a finished game to fall out. the good stuff lives in the back-and-forth — "make a platformer," then "add spikes to that gap," then "the jump feels stiff, loosen it," then "drop a checkpoint before the hard part." every message is a small move you make after watching the last one land, so the game you end up with is one you discovered rather than one you specified up front.

the first message barely matters. the next hundred are where the game actually arrives. when you stall, ask savi — she'll put something on the screen, you'll react, and you're moving again.

everyone's in the room

every spawn game is multiplayer from the first message. the world starts that way. you make something, send a link, and seconds later your friend is standing next to you in it. they can play what you've built or take the other end and build with you, same world, same moment. savi is right there too: three of you in one room, making one thing.

places and instances

a game can be bigger than a single world. places are its separate rooms — a town, a dungeon, a boss arena — each with its own terrain, atmosphere, and rules, joined by the doors and portals you set between them. instances are how a crowd fits inside one: when a second group walks into the same dungeon, spawn quietly hands them their own copy of it, so a thousand players can all be in the dungeon without ever bumping into each other. you get multiplayer at scale and never touch a server.

making money

games on spawn earn through spoins, the currency players spend inside them. charge at the door, give the first level away and sell the rest, run a subscription, sell cosmetics, or just let players tip you — tell savi which model fits and she wires it in. of what a player spends, you keep half and spawn keeps half.

it works the way roblox does. you publish, players show up, and you earn inside spawn while we run the servers and the distribution. there's nothing to export and nowhere else to ship it.

getting good in public

most of spawn happens around other people. the discord is where creators trade work, get unstuck, and find collaborators. spawnjam is the weekly jam — a theme, a deadline, prizes, and a real reason to finish something.

nobody arrives good. you build something rough, watch the person next to you build something better, work out what they did, and try it yourself. that loop, run in the open in front of people who are also still figuring it out, is how a creator gets sharp — and how the whole place keeps raising its own bar.

the toolset grows itself

people build wildly different things here — card games, voxel worlds, 2D fighters, UI-only games, sprawling 3D RPGs. no single editor could serve all of that, so spawn doesn't ship one. when you need a tool, you and savi build it: a cutscene editor for a story game, a wave composer for a tower defense, a loot balancer for an RPG. creators have already made all three.

then you publish your tool as a mod, and the next creator builds on top of it. the toolset is a pile everyone keeps adding to, and it gets deeper every week.

faq

frequently asked questions

What is Spawn?

Spawn is a place to make multiplayer games through conversation — side-scrolling, top-down, 3D, whatever the game wants to be. You bring ideas and taste, Savi brings hers, the game emerges from the back-and-forth. Creating is free, and so is playing.

Who is Savi?

Savi is your friend who happens to be the best game designer on the planet. She lives inside your world, builds alongside you in real time, and actually remembers what you're going for. Tell her "add a castle on that hill" and she'll build one — probably not the one you pictured. Tell her "actually, make it creepier" and she'll know exactly what you mean.

  • She shapes the worlds — terrain, models, environments, lighting, UI, pixel art
  • She runs everything underneath — game logic, multiplayer, NPCs with generated voices, in-game economies
  • She gives it feel — music, sound effects, and an opinion when you ask for one

Savi can see what you see — when you send a message, she's looking at the same thing you are. You can also interrupt her mid-build if you want to change direction.

You don't need to know how to create games. You just need to know what you want.

How do I start creating a game?

Go to spawn.co/create, press Tab, and tell Savi your idea. She'll start building it in real time.

Where can I download the desktop app?

Head to spawn.co/download. Chrome and the desktop app give you the best experience.

Is Spawn on mobile?

Coming soon. Mobile browser support is on the way.

How do I change my avatar?

Click your username in the top right of spawn.co, then click directly on the 3D model in the model window. Describe what you want to look like and Savi will generate it.

How do I change my username?

Click your username in the top right, then click the Pencil icon next to your current username.


Creating

What kind of games can I create?

Pretty much anything. Spawn supports:

  • 3D (first-person, third-person, open world)
  • 2D side-view (platformers, fighters, etc.)
  • 2D top-down (RPGs, strategy, etc.)
  • Multiplayer and MMO
  • Single player

All of it is created through conversation with Savi — tell her what kind of game you want and she'll set up the right camera, controls, and systems for it.

How do I create 3D models?

Describe what you want to Savi. Visual references help get better results.

How do I animate custom models?

When asking Savi to generate models, ask her to include default animations plus any specific ones you need — attacking, spellcasting, idle, etc. She has access to a library of basic humanoid animations.

How do I import my own assets?

Drag and drop directly onto the screen:

  • Images (.png, .jpg, .webp) — UI elements, textures, icons, sprites, backgrounds
  • 3D Models (.glb) — characters, props, weapons, buildings
  • Audio (.mp3) — music, sound effects, voice lines

Read the full announcement here

Can I add AI-driven NPCs?

Yes. Ask Savi to give your NPCs dialogue and generated voices — they'll speak their lines out loud.

Can I duplicate my game?

In My Games, find the game you want to duplicate, click the three dots in the top right corner, and hit Duplicate.

Can I create a game with a friend?

Yes. Inside your game: Tab → Multiplayer → "Copy Link." Share it with your friend. Once they join, change their role from Guest to Builder. Builders can create alongside you in real time.


Tips & Shortcuts

Any tips for getting the best results?

Think of it like shaping clay, not placing an order. The best games on Spawn are created through back-and-forth — start with a simple idea, see what Savi builds, then shape it from there. "Make a platformer" → "add spikes to that gap" → "make the jump feel tighter" → "add a checkpoint before the hard part."

The most common mistake is dumping a massive game spec and expecting Savi to nail it in one shot. She's at her best when you build together one step at a time. And when you're stuck, just ask her — she can suggest ideas.

Are there any shortcuts I should know about?

  • /focus — Savi becomes an opinionated game designer. She'll evaluate everything you've created and help you zero in on what's actually fun.
  • /debug — Savi hunts down bugs in your game and helps you stabilize things.

What is the Context circle?

The Context circle shows how much of your conversation history Savi is currently working with. It's not a loading bar — it indicates how much context Savi has available to understand your world. As you build, the circle fills up. If it gets full, Savi may need to let go of older details to make room for new ones.


God Mode

What is God Mode?

God Mode lets you take direct control of your world. Instead of describing changes to Savi, you can grab objects and move them around yourself — reposition them, rotate them, change their size. It's useful when you know exactly where you want something and it's faster to just place it yourself. Access it via Tab → God Mode.

How do I use Waypoints?

In God Mode, move your cursor to where you want something built and right-click to place a marker. Then tell Savi: "Build [x] on Marker [letter]." It's a way to point at a spot in your world and tell Savi exactly where to put things.

Can Savi create custom tools for my game?

Yes. Ask Savi to create tools tailored to your game. Some examples of what creators have made:

  • A cutscene editor for a story-driven game
  • A wave composition tool for a tower defense game — visualize, edit, and balance every wave
  • A loot and stats balancer for an RPG — see all your items and their properties in one place and tune them
  • An enemy placement tool for designing encounters

It's great for balancing, world design, and anything where you want to see and edit a lot of data at once. You can also publish tools you've created as mods for other creators to use, where they'll appear in Tab → Mods.


Publishing & Updates

How do I publish my game?

Hit publish and Savi will mock up a few box art options. Pick the one you like and your game goes live. You'll get a shareable link at spawn.co/yourusername/gamename/play.

Published games appear on the home page in the Recent tab. If your game picks up steam, it'll move to Hot, and if it gets enough likes, it'll land in Top. Once published, players can leave comments on your game.

How do updates work?

When you publish an update, Savi generates a name for the update — for example, add a bunch of critters and she might call it "The Critter Update." This does not affect the name of your game. Each update gets new box art and goes live on the home page. Your players will see that the game was updated and when.

How do I see how my game is doing?

Every published world has its own panel — plays, players, who came back, all ticking up live as people play, plus how each update landed. Savi reads the same numbers, so you can just ask her what's working and shape your next update from there.

Can I export my game to Steam or other platforms?

Spawn works like Roblox — you publish and earn inside Spawn, and we handle the servers and the distribution. There's no export to external platforms.

Can I see the code behind my game?

Yes. Press F3, find "Tome," and click "Copy for AI." You can paste it anywhere to review.


Playing & Community

What are Spoins?

Spoins are the in-game currency across Spawn. Earn and spend them as you play and create.

What is SpawnJam?

SpawnJam is the weekly community jam — a theme, a deadline, prizes, and a real reason to finish something.

Where do I find the community?

Join the Discord. It's where creators share work, get help, and find collaborators.


Earning Money

How do I make money from my games?

Through Spoins — Spawn's in-game currency. Players spend Spoins in your game, and you earn from it. Just ask Savi to set it up. She supports:

  • Pay to play — charge Spoins to access your game
  • Free demo + paid full game — let players try before they buy
  • Premium currency — sell in-game currency players spend on items
  • Paid cosmetics — skins, items, and visual upgrades
  • Subscription — recurring Spoins fee for access or perks
  • Battle pass — tiered seasonal rewards
  • Tips — let players support you directly

Revenue is split 50/50 between you and Spawn.

What is the Partner Program?

The Spawn Partner Program is for top creators — monthly stipends, perks, direct access to the team. Reach out to the team to learn more.


Troubleshooting

I found a bug.

Post in #bug-reports on Discord with a description and your debug info (Tab → Bug Icon inside your game).

My game is lagging or Savi stopped responding.

Do a full page refresh first. For the best performance, we recommend using Chrome or the desktop app. If the issue persists, describe what's happening in #bug-reports with your debug info. We're shipping performance improvements constantly.

Something looks wrong with my game — models missing, characters invisible, multiplayer stuttering.

Start with a full page refresh — sometimes changes fail to save and will appear correctly after refreshing. If the issue persists, ask Savi to help diagnose it. She can usually identify and fix the problem. If it keeps happening, drop it in #bug-reports.


Advanced

How do I set up custom camera controls?

Ask Savi. She can set up right-click-to-rotate cameras, scroll wheel zoom, free cursor mode, cursor-targeted projectiles, custom cursor icons — describe what you want and she'll implement it.

What are Places?

Places are distinct areas within your game world — separate scenes or levels. Each Place has its own environment, objects, and logic. Build new Places with Savi when you want separate areas (a town, a dungeon, a boss arena) that players can move between.

What are Instances?

Instances are copies of a Place that run independently. When multiple groups of players enter the same Place, Spawn creates separate Instances so each group has their own version. This is how Spawn handles multiplayer at scale.

How do I save player data between sessions?

Ask Savi to set up a save system for your game. Tell her what needs to carry over between sessions — level, inventory, progress, etc.

Why doesn't Spawn have a full 3D Editor / Photoshop / an IDE / advanced editor X?

People build a huge range of things in Spawn — UI games, card games, 3D, 2D side-view, 2D-inside-3D, voxel, and more. No single toolset works across all of that. Instead of shipping one-size-fits-all editors, Spawn lets you build the exact tools you need through Savi and share them as mods.

Ask Savi to build them. A cutscene editor for your story game, a wave composer for your tower defense, a loot balancer for your RPG — creators have already built all of these, plus CAD setups and custom editors. Publish them as mods so other creators can use them in their own games, and browse existing mods under Tab → Mods to see what the community has already made. As Savi gets better, the tools you can build with her will keep getting more powerful.

If there's a tool you want, the fastest path is to ask Savi to make it — then share it as a mod.

engine releases

every release, in full

Engine v5.2.34

Released August 22, 2026

  • Tweaking one knob on a custom material no longer breaks the whole material. Changing a single parameter on a script-built material (a glow's gain, a beam's density) used to silently throw away the script and every other parameter — the object snapped back to a plain solid-colored default, and it looked like your material was never there. Now a single-parameter tweak changes exactly that parameter and leaves the rest of the material alone.
  • Group objects that are already placed without anything moving: attachTo(rootId, { keepWorld: true }) adopts a piece under a parent while keeping it exactly where it stands — build a brazier or chandelier from placed pieces, parent them to one root, and move the whole thing as one object.
  • null
  • Saves and other database calls made by players' own behaviors were silently dying in relay-hosted rooms — on some games, all of them ("GameDB door forward has no lane on this wire"). Fixed: those calls now ride the relay's database lane and wait out reconnection blips instead of being dropped.
  • Roads can vary their width along the route. Give a road's width a list — one number per point, like [2, 8, 2] — and it narrows and widens smoothly between them: straights that open up, corners that pinch, a plaza that necks down into an alley, all on one spline. Edge lines, sidewalks, hedges, and lamp posts follow the changing edge, and driving/collision matches what you see. A single number still means one width for the whole road.
  • Deleted something and watched it pop back a few seconds later? Fixed. Your delete always saved — a laggy sync lane could briefly re-load the world from just before it and bring the object back. The engine now remembers what your last save contains and never rolls behind it.

technical notes

  • Dotted writes into a scripted material merge instead of gutting it: setObjectProperty(id, "material.params.X", v) now preserves the script ref and every sibling param (r-3fb1c3c8, ravi's dig 4f5f3ff8, dump e3b929c6; 5.2.34 rider). The dotted machinery already merged correctly — buildDottedPropertyMerge deep-sets into the property reader's flat echo, script included — but normalizeMaterialSpec only recognized the AUTHORED scripted spelling ({kind:"scripted", script:"scripts/x.js"}); the shape the engine itself emits — the reader's echo and the persisted mirror, {key:"Scripted", script:{ref, params}} — fell to the standard branch, where script and params are interpreted flat keys the override collector drops. So every dotted material write on a scripted material handed the writer a value it silently gutted to {key, params:{X}}: script ref gone, all sibling params gone, and a script-less key:"Scripted" renders as the Std/PBR default. That is exactly the merge properties.ts's flat-echo comment promises the echo exists to serve ("Readers feed dot-path writers … dotted setObjectProperty merges"), and the same lossless round-trip contract material-utils.ts pins for standard materials (ledger #173). It hit Savi three times in one session across spellings (material.params.* twice, material.script.params.*) — dump e3b929c6 carries five shaft-n-* prisms persisted as {"key":"Scripted","params":{"gain":1.5}} after the v116 receipts show the full 8-param scripted material: jacob's solid violet slabs. Two changes, one mechanism: (1) normalizeMaterialSpec round-trips both scripted spellings — object-shaped script.ref accepted, top-level params folded over script-carried params (the top level is always the newer write, since dot-path writers deep-set the spec spelling there) — which also repairs the tween-evaluator and god-mode gesture-commit lanes that re-apply the flat read-back through the same normalizer; (2) the dotted grammar canonicalizes material.params.* → material.script.params.* when the current material carries an object script (the model.materials → parts move), so the merge lands beside its siblings, the mirrored spec stays in the one persisted shape, and getProperty("material.params.X") answers what the write landed (read/write parity, both halves share canonicalScriptedMaterialSubPath). Whole-material assignment is untouched — replace stays its meaning. Red-first: the three dotted spellings, the v116→v119 persisted-spec fixture (full material before, dotted write, full material after minus only the addressed leaf), the whole-replace pin, and the mirrored-shape reload path (applySpec re-applying {key:"Scripted", script:{ref, params}}), all red on the old normalizer. Not touched here: a gutted legacy {key:"Scripted", params} with no ref anywhere still folds key-only (no ref to recover), and the b2 half of the dig (reversed smoothstep edges emitted verbatim to WGSL) stays open.
  • The merge contract is now pinned for EVERY dotted subtree, not just material (jacob, in-thread 1787363264: "that foot gun is insane. remove it for all cases not just this one"). The general contract: a dotted setObjectProperty("<parent>.<leaf>", v) changes ONLY the addressed leaf of the parent's echo — every sibling the reader echoes survives — while whole-subtree assignment stays a replace. New census suite (dotted-write-merge-census.test.ts) probes 17 families generically (read echo → dotted-write one leaf → read again → after must equal before with only that leaf deep-set): feetPosition, scale, primitive, material (echo-space), text, sprite, sign, light, audio, particles, fx, physics, visibleRange, jiggle, layout, npc, mixer — plus the families pinned in their own suites (rotation euler leaves, model parts/materials, animated3DCharacter part materials). Census verdict: buildDottedPropertyMerge merges by construction — clone the reader's echo, deep-set the leaf, hand the whole parent to the writer — so the replace-instead-of-merge class only exists where a writer's normalizer refuses the reader's echo and drops what it doesn't recognize; the scripted material was the one live instance in the registry, and the census pins each family against that asymmetry returning. Named bound on the census: attachment (writes ride the parent verb), scatter (template arrays — dotted indices are not part of the taught grammar), ik (channel values carry no sibling records), spline (writer runs the preview pipeline a bare test world doesn't host).
  • setParent/attachTo grow a keepWorld: true option (r-0752ba7b, jacob's brazier dig 3acd7d3f §2) — the world-preserving adopt, three.js attach() semantics. Unspecified position/rotation/scale now default to the local values that keep the child's current world pose under the new parent (rotation/scale invert the parent frame up front; the position leg converts through resolveWorldFeetPositionWrite after the link lands, so pivot folds and socket/part anchors peel exactly as the hierarchy solve composes them). The snap default is unchanged — the equipment idiom and the spec parent write lane both rely on it. Frames with no honest conversion (bone attachments, camera subtrees, ~zero parent scale axes) refuse loudly before any mutation.
  • The private-transport port's admission bar fails OPEN on an absent generation stamp — the port-protocol skew class dies structurally (dig d75f7599, 2557's second lane). The worker's PrivateClientTransport gate (row 2515, 37f6a0c080) was fail-closed on ANY mismatch — if (message.generation !== this.dialGeneration) return; — but the headless room-host shell BAKES the bridge into the container image and DOWNLOADS the worker per room pin, so the two halves of a protocol designed as one bundle can ship from different builds. The pinned b5f063f48e-era image's pre-stamp bridge posted {type:"open"} with no generation; 5.2.31+ workers read that as undefined !== 1 and silently dropped every open/message/messageBatch/close — open never flipped, the hello could never leave, the 4408 close never reached the reconnect ladder, and the summons parked 180s to exit 6. 24 apps wedged for 4 days; no engine pin could fix it (every post-2515 worker carried the bar, and the stale bridge couldn't answer it). The cure per the dig's fix-shape: generation !== undefined && generation !== this.dialGeneration (staleGeneration) — an absent stamp proves the far side predates stamping and is delivered; a mismatched stamp is still a superseded dial's late flush and still drops. The 2515 protection stands whole: in browsers both halves ride one bundle so absence never occurs, and the stale flushes the stamp exists to kill only ever arrive STAMPED from a post-2515 bridge. Red-first: unstamped open/message/messageBatch/close from a hand-posted legacy bridge reach the client (red on the fail-closed bar), a mismatched stamp still drops, a matching stamp passes (room-transport-bridge.test.ts). The protocol doc now names the reader-leniency law: every wire-shape change owes the question "what does the OTHER era's half do with this message?". Ships with the trigger half of the same PR: container-build.yml's auto-advance path filter (and container-image-drift.yml's lineage twin, lockstep) now watches the baked↔downloaded port-protocol seam roots, so a protocol change recooks the image instead of drifting under the pin.
  • Player-behavior api.sql on relay-hosted singleplayer rooms no longer dies at the relay egress' dead-lane drop — the forward gate is tri-state honest (ledger 2438 P1, medivhus's Cardhouse; field kin: the Grind95 save write lost in a host-flap window, 08-19). The forwarded GameDB door's eligibility gate (tome/api/game-db-forward.ts isGameDbForwardEligible) read the 2-state live wire sniff (clientWireMode !== "relay"), and the sniff's "undetermined" hold — the first-frame window AND every between-sockets redial (rejoin, suspend/overflow re-bootstrap, host-election flap) — stayed forward-ELIGIBLE by documented design. Every behavior api.sql issued in a hold enqueued a SqlRequest the egress deferred, and the welcome proving relay dropped the whole backlog typed at the dead-lane drop (game-db-forward.ts rejectDroppedForwardedGameDbCall — "GameDB door forward has no lane on this wire"). On Cardhouse (singleplayer pin, relay-hosted dev room-1) the window read permanently open across three engine versions: 100% of player-behavior SQL dropped while the DB sat provisioned and healthy (migrate_applied=1134), so the creator's all-truth-in-SQL game ran stateless for players. The cure per the row's own prescription: the gate now reads clientRelayWireTruth (the pin-04da3c48 pattern netcode's travel-authority forks already use) — the live sniff when determinate, else the welcome-edge stamp (ClientRelayWireModeResource). A stamped-relay world in the hold falls through to the relay lane, whose posture is composable there (the relay identity survives socket bounces) and whose pre-send park (ledger 2381) holds the call replay-safe until the session opens — hold what can be held. Fresh worlds whose wire never proved relay (stamp absent) and kernel-stamped reconnects keep today's path exactly; the egress dead-lane drop stays as defense-in-depth and now fires only on a fresh world's pre-first-welcome window or a resolution regression. The security boundary is unmoved: relay wires still carry no control-channel door, and the diverted calls ride the relay player lane's existing digest-and-verified-identity argument (or the room_host lane on a credentialed seat). Red-first in game-db-forward.test.ts: the between-sockets fixture (stamp relay, sniff undetermined) resolves the relay lane, parks, and SENDS when the wire opens — nothing touches the dead control lane; the kernel-stamped hold keeps the forward lane.
  • Road width arrays are honored per-point — the schema's existing promise, kept instead of averaged (ravi's vocabulary datum from the #13374 teaching-child; 5.2.34 rider). SplineSpec.width has always typed number | number[], but resolveRoadVisualWidth (spline.ts:1142) silently averaged an array to one scalar — Savi writes width: [2, 8, 2], gets a uniform road, no error (the #13371 build was fooled exactly this way; ravi's fix-forward taught around it in world-composition.md, now reverted). The road surface now lofts a per-frame half-width: width[i] rides points[i] (clamped to the last entry, like rolls) and interpolates linearly by arc distance between them, through the same buildPerPointDistanceSamples parameterization the rolls channel uses — one mechanism, two per-point channels. Roadside features track the varying edge (edge lines, sidewalks, hedges, and lamp posts all take their lateral offset from widthAt(distance)), and the collider is definitionally the same mesh (custom-primitive physics reuses the surface's BespokeGeometry), so collision follows the taper — the lie can't move. Scalar and uniform-array roads are byte-stable: a cross-revision dump of a full-roadside scalar road (16 generated entities, 8 geometries, curving 3D centerline) diffed byte-identical against master. Invalid array entries fall back to the average of the valid ones; an all-invalid array keeps DEFAULT_ROAD_WIDTH — the behavior arrays always had. Red-first pins in spline-road-per-point-width.test.ts: [2, 8, 2] narrow-wide-narrow at the authored points plus an interpolated midpoint, scalar ≡ uniform array, edge lines hugging the varying edge, and the collider-is-the-mesh agreement.
  • A confirmed spec save is now a head claim — the stale-refold resurrection class dies (ledger 2599 P1, cal's god-mode resurrections: thread 1540752985330819262, dig 1580caec). On relay rooms, the spec-save confirm (kiln:spec-save-result ok) splices the batch out of pending custody (relay-spec-save.ts onResult) — the instant the fold-rebase overlay stops protecting the write — but DISCARDED result.version: nothing recorded "my write is folded into every durable version ≥ N". A client on the F3 heartbeat-beacon fallback lane (relay-join.ts onHeartbeatPointer — which re-states the RELAY's checkpoint, one version behind the client's own just-confirmed write by construction, and cal's ONLY spec lane: 31/31 SPEC frames missed on the ring) would then fetch and fold the PRE-write doc, and applySpec's spawn pass re-materializes any spec row with no live entity (interpreter.ts: !world.hasEntity → spawn). Tree deleted at v21943; the beacon folded v21942; tree back ~3s later — on tape twice in the ECS capsule (two #delete:click destroys of the same id, 3s apart, zero inputs between). The cure per the row: the ok verdict's saved version now feeds the SAME spec-claim door SPEC frames and the F3 beacon share (RelaySpecSaveScheduler.onConfirmedSavedVersion → netcode → transport relayNoteSpecSaveConfirmed → relayJoin hooks → RelayJoinCoordinator.onSpecSaveConfirmed → armSpecClaim), so the client never folds a doc older than its own confirmed write: an older beacon can never displace the claim, an in-flight older fetch discards on resolve (the existing supersede check), and once the confirmed version folds the adopted head covers everything below it forever. Claim lifetime is untouched — room-lineage scope, survives socket bounces, dies at resetForNewRoom — and at-or-below-head confirms are covered no-ops, so the ordinary lane (own SPEC frame already folded the post-write doc) fetches nothing. Red-first against a real tome world (real fold-rebase, real applySpec spawn pass): the tape (delete confirms at vN, the beacon names vN−1 — the entity must not respawn), the crossed sub-ordering (pre-write fetch in flight when the confirm lands — resolve discarded), folds above the claim passing untouched, and resetForNewRoom clearing the claim (relay-spec-confirm-refold.test.ts; scheduler-side verdict pins in relay-spec-save.test.ts).

Engine v5.2.33

Released August 22, 2026

  • A one-off network hiccup while the engine downloads no longer kills a player's first open. If one piece of the engine fails to arrive, the game now quietly re-requests it once before showing an error — the class of "it just showed 'something went wrong' the first time I opened it" caused by a single flaky fetch heals itself.
  • Vehicles and other built objects no longer come back scrambled after a reload — wheels, seats, and parts stay exactly where you put them, every session.
  • Dragging an object no longer teleports it back to where it last saved. After a stretch of continuous dragging, grabbing an object could snap it to a stale position (roughly where the last autosave put it) and every drag after that kept teleporting. The drag now always starts from where the object actually is in your hand.
  • Things stay the right size when you walk into a new area. In multiplayer rooms, objects with a size limit (layout) that were built while your game was still electing a host could show up giant — a 10m watchtower rendering at 45m — and stay giant until the room restarted. Now the moment your session takes over simulating an area, everything in it snaps to its correct fitted size.
  • Scripted edits to painted terrain and scatter no longer make the rest of the paint vanish in the running game.
  • Middle-click is now reliable. Binding the middle mouse button no longer fights the browser — bound middle-clicks can't trigger page autoscroll or other browser defaults — and a middle button held when pointer lock drops (Esc, opening chat) no longer sticks "down" until something else resets input. Actions bound to middle fire on press and release exactly like any key, every time.
  • Falling out of the world while seated no longer welds you under the vehicle. When the engine's fall-catcher or terrain rescue moves a body back to safety, a rider still parented into a seat is unseated first and lands at the rescue point as a free body — before, the rescue position could be re-read as a seat offset and leave the player stuck under the cart until a script intervened. The unseat-before-rescue guard scripts wrote by hand is now the engine's own behavior.
  • Custom crosshairs are yours now — the platform's white pointer dot no longer sits over your game's own crosshair. It stays hidden until a player actually moves the mouse in the overlay, which is the only time anyone needs it.
  • Old open tabs can no longer cross your live game's players into your editing room. A tab left open from before a recent fix could reconnect players into the room you edit in — reading half-built work and manufacturing wipes. The server now refuses those stale connections at the door and the page reloads itself into the right room.
  • Ctrl-Z now survives a reload in multiplayer worlds. Before, undoing a delete brought the object back on your screen but never re-saved it — it silently vanished the next time the world loaded (and if it lived in a side map, even a saved restore could teleport to the main map). Now an undone delete is really back: full object, behaviors and all, in the place it belonged.
  • Ghost copies of players can no longer haunt multiplayer rooms while our builder reconnects. A half-booted background session used to be able to run stale copies of your game — old script versions writing old state onto live players' screens, in loops of up to three minutes. Now a session that hasn't finished joining simulates nothing at all: your world only ever has one live simulator per entity.
  • "Which script is eating the tick?" is now one question, not days of ablation. In multiplayer rooms, api.getPerformanceSnapshot() (Savi's perf read) now answers with the hosting player's actual per-script sim cost — cumulative ms per script file over the measured window — plus how many scripted entities ran vs. were schedule-skipped each tick. Before, that data lived on the hosting player's machine and the read came back empty.
  • On slow or busy computers, the game no longer tears itself down to "recover" from its own lag — the surprise "Connection lost. Reconnecting…" after a long freeze, and waves of textures failing and retrying, both came from the engine misreading its own slowness as failure. It now tells the difference and rides the lag out.
  • Grass is no longer black from the far side. Grass blades and plant sprites used to render as black shapes when you looked at them from the side facing away from the sun's shading — fields showed dark patches that moved as the camera turned. Every blade now reads the same green from both sides, the way ground cover should.
  • Your game UI can now save files to the player's device. Export buttons built the normal web way — make a Blob, point a download link at it, click it — now actually download: story text, save files, level exports, screenshots. Before this, the export button did nothing at all, with no error anywhere.

technical notes

  • A failed boot-critical dynamic import gets ONE cache-busted retry before boot death (tiger's onboarding dig 5b47a1e5, fix 1; 5.2.33 cargo). The client entry's engine-chunk imports (worker-browser-host, client-sight-frame in loadCreateBrowserAppHost) now run through importBootChunk: on a module-fetch rejection (Chromium/Firefox/WebKit shapes matched by message), wait a 250–500 ms jitter, then re-import the failed URL with ?retry=1 appended — a request no poisoned intermediary cache (and no memoized module-map failure) has ever seen. WebKit's URL-less rejection falls back to re-invoking the same specifier once. A second failure rethrows with " (after retry)" appended, so the [kernel] Startup failed console line distinguishes retried-death from one-shot-death; evaluation errors (module loaded, then threw) are never retried. Exactly one retry, no loops — the burden of proof (the dig's receipts: transient edge 502s, ~10 events/3 sessions/day, same URL 200 seconds before and after, always a first open) lives on the helper.
  • Child transform ownership: a parented child's Local* is AUTHORED truth and the client local-transform projection can no longer overwrite it. The projection's default flipped from "re-derive Local from World for every parented child, minus four excused guards" to two explicit jobs — SEED a Local lane whose row hasn't arrived yet (wire ordering), and OWN the lanes of dynamic-bodied children, where physics writes the world pose and local is genuinely derived (physicsOwnsPose, one rule shared with the hierarchy solve). The god-mode-drag, stand-rider, and bone-subtree guards are deleted — each was one more authored-row case discovered the hard way, and the default now covers them (the stand-parent seam test flipped from negative control to positive pin). Root cause this kills: one tick of parent/child frame incoherence (a reload's journal-restored hull vs its spec-seeded children) was divided into the child's local offset and became permanent — vehicles reloaded scattered and rode rigidly-wrong forever (reload-scatter-repro.test.ts, red pre-fix with the exact predicted arithmetic).
  • A translate drag's x/z base is the press-time LIVE feet, never the spec row's (tucker's drag-teleport, walk thread 6:07pm 2026-08-20; 5.2.33 cargo, the 5.2.32 cut predates it). The translate pad and the spline spline:move pad based their slide on the spec row's terrain-anchor x/z (authoredTerrainAnchorFeet → getObjectSpec). On a relay client the doc row only advances when a save's fold echoes back — release commits ride the accumulator → flush → kiln, never the local doc — so the row lags the hand by a whole save round trip, and every translate drag writes the terrain form { x, z, y: { terrain } }, arming the anchor read. Once the first save echo landed (~3–5s into a drag chain), every later press captured the LAST SAVE's x/z as the slide base and the first drag frame teleported the object there + ray delta — degraded for the rest of the session ("EVERY time you drag and release it will like teleport … snaps back roughly to where the save was"). The authored anchor now supplies ONLY the vertical pin (offset + write form), which is all cal's Final Abyss burial fix ever needed from it; the commit seam's zero-move rule (GESTURE_LIVE_FEET_KEY) already keeps an idle touch from baking the f32 live base, so no ulp-smudge returns. Pinned by drag-chain-release-commit.test.ts: the full relay-client loop (god-mode gestures + persist flush cadence + forward recency + simulated kiln fold/echo) runs a six-gesture drag chain with saves landing mid-drag — red on the old base (release at x=8 lands at 4: the stale anchor 2 + drag delta), green now, with every release's save minting and live/doc/durable converging on the drag's end. Named residual, same family, out of this pin's scope (self-healing in one round trip): a stale echo landing un-held after the recency horizon expires transiently reverts the live position until the client's own save echoes back.
  • Geometry fit re-solves on the hostship-gain edge (ledger 2566 — the 45.7m giants on every post-boot place expansion; live-verified in 46bfb1da's rig on master and 5.2.31). On a relay client the boot applySpec builds EVERY place's entities (client mode has no residency filter), and the fit drain runs before the election names this client host of anything — the observer gate consumes the TomeLayout dirty marks as bookkeeping (correct: no local GeometryScale write while another realm simulates). But nothing re-marked when hostship arrived: the header's promised rescue ("the first host's hydration seed re-marks and solves it") did not exist on the relay tier — the only seed runs at feature mount, long before hostship — so entities built while observing NEVER solved fit after the client became host, rendering at raw × authored scale (local=3.808, geom=NULL, world=3.808 for the whole session). The cure is the fit half of scatter-host-redrive's gain edge (ledger 2194's detector — one edge detector, two redrives, no second WeakMap family): on the edge where a place is GAINED, remarkGeometryFitForHostGain marks every fit-bearing entity and the same tick's drain solves it. Over-marking is free — writeGeometryScale identity-skips values the replicated rows already answer, observer marks are consumed as bookkeeping next drain, and on the gain edge this realm IS the simulator, so the single-writer treaty holds. The false header sentence is corrected in the same diff. Pinned red-first in geometry-fit-host-gain.test.ts (apply while hosting nothing → drain → GeometryScale correctly absent → TomePlaceHosts names the local client → tick → GeometryScale 0.2251).
  • api.updatePlace's live application now applies { merge: true, chunks, removedChunks } field deltas with the same incremental arithmetic both durable folds use (spec-mutation-fold's mergeFieldChunks, now shared by import) instead of installing the delta as the whole field — a partial-update-shaped write no longer wipes the live field while the stored copy stays correct (dig 5d20101b, ledger 2583).
  • Middle-mouse edges reach the ring (r-83fc347c dig on @jissi/blood-and-mead 660e9c3e — five Savi hardening rounds proved the script maximally defensive before the dig found both seams engine-side; 5.2.33 cargo). Two seams in raw-capture.ts, red-first in middle-mouse.test.ts. (1) The r-c5a11032 claim extends to middle: a game binding middle — on: "Mouse1" or a requiredMouseButtons combo modifier — claims the button's browser defaults for the session: preventDefault on pointerdown/up + compat mousedown/mouseup + auxclick (Windows-Chromium autoscroll engages off the unprevented compat MOUSEDOWN, auxclick carries its own defaults — paste, link-in-new-tab — and the pointer event's cancellation reaches neither), gated only on the claim, never on target or mode. Unbound middle keeps every browser default; the side-button claim rides unchanged under the renamed deriveClaimedMouseButtons/setClaimedMouseButtons. (2) The pointer-lock-loss desync closes: handlePointerLockChange cleared heldButtons/mouseButtonsHeld on unlock without appending MOUSE_BUTTON_UP, so the sim's mouseButtonsDown kept the button until the next RESET — the physical release over UI died on releaseMouseButtonIntent's !heldButtons.has gate, a hold binding wedged (works/stops/works-again by construction — chat/blur un-wedged it), and syncMouseButtonsFromMask couldn't heal it because the main-thread set was already empty. Held buttons now SURVIVE the unlock and the real release flows through the paths that already exist: the canvas-begun-drag carve-out (an up over UI passes while heldButtons has the button), the buttons-mask heal, pointercancel, and the blur/visibility RESETs. Deliberately neither alternative: a synthesized UP fires activeOn:"keyup" bindings (processMouseButtonUp pulses unconditionally — Esc toggling the lock would fire actions the player never released), and a silent RESET sweeps held movement keys whose keyups still arrive (lock loss keeps key focus). Rider: the docs generator's InputActionDef prose line finally lists "mouse4" | "mouse5" — the r-c5a11032 tokens never joined it. Teaching-lane note (the spec is the creator's, not ours to edit): Savi's field patch comment in blood-and-mead's carry.js teaches a false engine model — "the engine honors one source per action" — buildDefaultBindings compiles keys AND mouse AND gamepad each to a live binding for the same action; every future dump read of that comment re-teaches the wrong shape until corrected.
  • Rescue-class world-lane writes unparent first (the OOB writer contract — golf-pack 2af2f101 flag 1 on #13317; 5.2.33 cargo). The two engine writers that teleport possibly-parented bodies — the out-of-bounds respawn (respawnBodyAt, tome/systems/out-of-bounds.ts) and the terrain chunk-rescue (setRescuedFeetPosition, terrain-systems-shared.ts) — now detach the target's TomeParent edge immediately after the world write, so the detach rebases Local* to the rescued pose itself. A bare world write onto a still-parented rider never meant "rescued": the client local-transform projection divided it into a parent-relative Local* (the respawn point re-read as a seat offset — the Purr Putt dump f16eccb2 cat welded under the buggy), and under #13317's seed-missing ownership rule the same write becomes a silent no-op (the next hierarchy solve recomposes World from parent ⊗ local). Unparent-first is upstream of that divider, so the contract holds under both projections. The detach is one primitive, extracted from ObjectAPI setParent(null)'s null branch into tome/hierarchy-detach.ts — one implementation behind the script lane and both rescue writers, resolve-tolerant for the engine-only worlds the chunk-rescue also runs in.
  • The platform's pointer-lock cursor dot (kiln's FakeCursor — host chrome's stand-in for the hardware cursor while the game iframe holds the lock) is now hidden-until-movement: a fresh overlay attach re-anchors it at viewport center INVISIBLE, and it renders only once the player actually mouses the overlay while visible-eligible (overlay open + locked). Motion means the player needs the pointer; lock-and-look crosshair games never move it, so their players never see a platform dot over the game's own reticle (savi platform-gap r-990382a0, app e742374d). No authored key — the earlier camera.pointerLockDot draft was withdrawn because authored-off would blind the overlay's only pointer in exactly the state it exists for; the default is now right structurally. The movement gate reads only the engine-relayed movement deltas (computed inside the engine's own accumulated cursor space), so the attach re-center and the unlocked snap-to-real-coordinates — re-anchors, not motion — can never fake a reveal, and the reveal rides the same call stack as the first qualifying move (no added latency). Lock loss and overlay close reset the gate: every re-anchor returns to hidden-until-movement.
  • The relay's cross-mode-room join refusal becomes a client reload verdict (#13303 residual; 5.2.33 cargo). The split-brain belt refuses a LIVE-surface join addressed at the dev-coordinate twin of its room (a prefix-less legacy id whose live- form kiln's rooms.mode registry names a real live room) with RELAY_CLOSE.crossModeRoom (4412). The one honest producer of that address is a stale pre-13303 kiln PARENT bundle — fresh loads normalize at buildWebSocketUrl, and disconnect-rejoin remounts the iframe, never the parent that mints the URL — so nothing inside the iframe can heal it. The engine client maps 4412 as never-retry (room-runtime's close table: every redial re-asks the same stale parent for the same wrong URL), and the host answers with two moves: spawn:kernel:reload-verdict to the parent page (kiln's reload-verdict relay performs a full window.location.reload(), loop-breaker bounded — the reload picks up the fixed bundle AND lands the session in the live room) and an error wall carrying terminalBootCondition: stale-bundle-reload for parents that predate the handler (boot-stall stands its remount ladder down; the copy names the manual reload). Pinned by room-runtime.test.ts (4412 never retried, reported willReconnect:false) and relay-side by marker-door.workerd-test.ts (refusal + relay.cross_mode_join_refused + fail-open on an unregistered twin).
  • God-mode undo/redo commits on the relay tier (dig 32d6b57b — Cal's hollowmass, hm-pen-16). undo()/redo()'s client branch was prediction-only by design: on the kernel tier the server world drains the same replicated god:undo input and runs the authority branch, so the client's ECS-only apply is exactly a prediction. The relay tier has no server world — the relay-wire client IS the god-mode persist authority (isGodModePersistAuthority) — so the prediction was the only run and no structural undo ever reached the doc: every ctrl-z restore of a deleted object vanished on reload (game_specs receipts: the delete sweep landed at v21840, zero re-adds ever). Now a relay-wire client takes the authority branch — full spec-mirror bookkeeping plus the mutation enqueue, which leaves through drainTomeSpecMutationQueue's relay forward and kiln's fold, batched (undo of an N-row cascade is one enqueued batch, chunked by the existing 8192-count forward), and the restore carries the FULL captured row: behavior, state blob, tags, model. Kernel-tier clients keep the prediction-only branch byte-for-byte (a local enqueue would double-record against their server's run). Also fixed in the same seam: the authority branch's durable respawn mutation is now place-stamped (mirror of durableSpawnSpec/commitObjectIntoSpec) — the captured row is place-stripped by mirror convention and kiln's fold resolves a placeless NEW row to defaultPlace, so an undo (or redo of a spawn) in any other place restored the row into the wrong map on reload, on every tier.
  • Relay client worlds simulate NOTHING before Ready (ledger 2557's root-cause cure — the crownfall zombie-simulator dig 998860d4; 5.2.33 cargo). A wedged builder-body summon is a full engine client by construction, and its pre-Ready world holds only checkpoint/journal fossils — a host table naming its own stable clientId at a dead epoch, a suspension row still stamping a player who is live elsewhere. The simulate-when-unknown authority defaults let that world adopt hostship from the fossil table and (through the stale suspension row skipping the owned-by-client exclusion) a LIVE player's whole envelope: a stale simulator for up to its 180s watchdog window, on a summon loop, whose state writes fan to the room as ordinary keyed heads — seele's four divergent player copies. The cure flips the default at the two derivation sites: getLocallyHostedPlaces answers empty on a relay client world that has never applied its relay Ready grant (so the exclusion word can never read host-simulated — simulation now requires a POSITIVE authority fact: own envelope, local client-realm spawn, sim lease), and shouldSimulatePlaceHere's no-local-player mode-both lane is closed on the same worlds (that lane is the container/harness affordance). The Ready latch is the welcome-edge wire stamp (ClientRelayWireModeResource, written per determined Loaded welcome and kept across socket churn — a Ready world keeps its scope through reconnect outages); the realm test is the transport's live wire fact (ClientWireModeResource, moved to the netcode-transport leaf beside its type so control-utils can read it without a cycle through relay-world-sync). Kernel/container tiers, singleplayer, and bare harness worlds keep their semantics byte-for-byte; the legitimate suspension takeover is untouched (relay hostship is only AUTHORED at session-live, past Ready — the takeover test pins the post-Ready lane still granting).
  • Sim-cost attribution reaches the exec seat on player-hosted relay rooms (r-f67665bf — Savi's platform-gap from Nine Heavens 330e6aea; 5.2.33 cargo, the 5.2.32 cut predates it). Three pieces, all routing existing measurements: (1) behavior-update's timing gate widens to multiplayer client-auth tabs — under client-auth every update a tab runs is its own simulation duty, so the elapsed the watchdog machinery already shapes now feeds SimAttributionResource there too (per-script rings, keyed by script file name; the budget watchdog stays authority-gated — attribution never parks anything). (2) The client perf report (engine.perfAttribution) carries per-script cumulative window ms (sim.top[].totalMs), the behavior-crossing census (behavior: totalEntities/updatedEntities/scheduleSkippedEvery/scheduleSkippedNear — behavior-update's existing per-tick debug resource, one get per report), and hostedPlaces (whose sim the rows attribute). (3) A relay lane (relay-perf-attribution.ts, the client-sight idiom): getPerformanceSnapshot()'s arm fans an EPHEMERAL perfmeasure-request to every member, each arms its own bounded reporter and remembers the asker, and reports return as DIRECTED EPHEMERAL frames consumed into the asker's local mirror through the same validate + rate-limit door the kernel command lane runs — engine.perfAttribution's relay lane flips "none" → "local" (consumed at egress, never journals, nothing sends while nobody asks). Kin 2422 (getClientHealth self-only on relay rooms) is the same lane shape — an always-on sibling would fan engine.clientHealth the same way.
  • Worker self-stall no longer reads as wire silence (dig 8e92f5ac: playtest-3/tophat — a 33.7s sim tick on a starved CPU minted close 4440 and remounted the iframe twice). The runtime worker's sim-health monitor now remembers its most recent blocking tick (lastSelfStall), and the upload ack-liveness heal consults it beside the existing row-2515 page-thread hold: a self-stall of at least one heartbeat cadence that ended within the current bound means the measured ack silence was unobservable (our own loop couldn't send heartbeats or process acks), so the verdict HOLDs and re-arms instead of closing. A real ack death still condemns one full bound after the wake, judged on observable time only.
  • The texture bake watchdog discriminates starved from wedged before killing (same dig: three kill-all waves = 46 bake retry-failures on one starved session whose scripts baked clean minutes later). Zero progress across BAKE_WATCHDOG_MS is now a PROBE, not a verdict: the transport pings the worker, and only a ping unanswered for BAKE_WATCHDOG_PING_GRACE_MS (10s) is a wedge — a starved worker answers late (the bake pumps one budget slice per macrotask, so its loop stays alive) and just gets a re-armed window. A genuine kill fails ONLY the wedged bake; queued bakes carry across to the fresh worker instead of mass-failing.
  • Back-face grass shades like the carpet again: the two-sided normal flip no longer touches terrain-sampled normals (dig f78408c1, jacob's dark-grass thread 1787353911; 5.2.33 rider). Grass/sprite decoration cards deliberately shade with the placement sampler's terrain up-normal (decorationSamplesTerrainNormal — the classic "grass shades like the ground" trick), but the decoration fragment applied its two-sided GEOMETRIC flip (N *= select(1.0, -1.0, !front)) to every variant — so each back-facing blade pixel was lit with a normal pointing straight DOWN: zero sun at dot(N, L) ≤ 0, the ambient hemisphere at its darkest direction (the sky-v2 starter's SH probe IS the ambient), black blades salted through the meadow that swapped sides with look direction (tucker's view-dependence datum). Fixed in both lanes: the fixed lane forks at WGSL emission (variant.terrainNormal ⇒ no flip; geometric variants keep it — compile-time, zero runtime cost), and the scripted lane — where the normal choice is per ITEM at runtime (item.fx.w; one shader serves the whole layer) — gates the assemble.ts flip in the fragment via the new vertex-path keepBackNormalWgsl seam (frontFacing || (item.fx.w > 0.5)), with the item uniform made fragment-visible in the scripted group-3 layout. Red-first receipts: WGSL emission pins (decorations.test.ts, decorations-scripted.test.ts — red on master's emission), plus the new real-device gate scripts/verify-decor-backface (verify-terrain-jitter mold, production pipeline + card geometry + record ABI on the L4): on master's flip the back-face blade reads centerLuminance 0.0037 vs front 0.3857 (the black blade, on silicon); post-fix 0.3827 ≈ front (the carpet property), and the geometric control (0.278, lit only BY the flip) held in both worlds — the fork's other side keeps its viewer-facing flip.
  • Game UI can save files: allow-downloads graduates onto the realm iframe sandbox (r-19dfda8a — Savi's platform gap, Story Builder ee0eeb9f; 5.2.33 cargo). REALM_IFRAME_SANDBOX lacked the token, so the sandboxed-downloads flag refused every authored export — Story Builder's Export window (Blob → URL.createObjectURL → a[download] → click inside a real user click) SILENTLY no-opped: no throw, no event, no file, nothing script-observable (the silent-no-op family). The graduation is one token; the security shape is measured, not assumed, in the new browser-gate suite tests/browser/suites/realm-download.pw.ts (chromium + webkit): (1) under the byte-exact sandbox a real click's Blob export fires the browser's own download — user-visible, cancellable, riding the browser's download pipeline; (2) a control frame with the token stripped stays download-free on the same real click, proving the token is load-bearing and pinning master's exact pre-fix no-op (red-first receipt: on master's tokens the suite reds with a 15s download-event timeout off a real click); (3) a documented-verdict SENTINEL: a first NO-activation programmatic click currently fires the download in BOTH engines — the HTML "allowed to download" check makes gesture-gating UA-optional and neither engine takes the option — pinned so a flip in either direction re-opens the decision. The jsdom pin in realm-manager.test.ts holds the exact token set (allow-scripts allow-modals allow-downloads) so every future sandbox edit is deliberate. Named residual (the filing's ask-2): a browser-refused download is UNOBSERVABLE from inside the frame — the click returns void, no error event exists, the refusal lives only in the browser's console — so no loud-failure machinery is buildable frame-side; the graduation itself removes the one refusal lane we controlled.

Engine v5.2.32

Released August 20, 2026

  • Meshes reworked while your tab was hidden now reappear when you come back — big generated meshes edited behind a hidden tab could return solid but invisible until each one was nudged by hand; the engine now spends no retry budget while nothing is being drawn, re-requests everything parked the moment the tab returns, and tells Savi how many meshes are undelivered on a player's screen if any stay stuck.
  • Objects spawned over the wire show up for everyone, every time. Before, an object created through certain save paths could land in the game's saved data but never appear in the live room — invisible to you and every joiner until someone else happened to edit the world. Now the room builds it within a fraction of a second, no outside edit needed.
  • Fixed: custom cursor sprites no longer swallow mouse clicks. Games that replace the native cursor with an image (draggable=false, pointer-events:none) work again on desktop — clicks pass through to your buttons and your world exactly like they did on 5.2.21.
  • A model that hits a momentary server hiccup no longer reads as dead. The placeholder used to flip to "Couldn't spawn" — and Savi's eye to "a receipted death" — off one failed read, even when the asset was fully imported and serving; creators were told to regenerate or rename assets that were fine. Now the engine keeps probing through transient trouble, tells you exactly what it measured ("N reads have failed so far") and only declares an asset dead when the server itself confirmed it.
  • Shadows from static models come back on iPhone and iPad. On some Apple devices, props and buildings imported as unskinned 3D models silently stopped casting shadows (characters still did). They cast again.
  • Multi-area games stop losing areas: walking back into an area that had emptied out (a prologue street, a lobby, an arena) now reliably brings back everything you built there. Before, a busy room could bring the area back as an empty box until the room restarted.
  • Cap your game's frame rate — engine.graphics.maxFps caps how fast the renderer draws (great for heat, battery, and steady pacing on phones), and players can set their own lower cap in settings. Your game logic runs exactly the same; only the drawing rate changes.
  • Big worlds load in smoother. While a large game streams in, the renderer used to spend a chunk of every frame re-sorting the scene; that cost is ~10× smaller now, so the first seconds of a heavy world hitch less.
  • Editing a shared library file your game's UI uses now reliably updates the screen: before, if the room's update lane hiccuped, a lib-file edit could leave the old UI showing forever — while the diagnostics insisted everything was fine — until the tab reloaded.

technical notes

  • Geometry-wait parks re-arm on renderer-pause exit (dig 5b3d86d5 — derrf's hidden-tab rework). Three moves on the #713/#983 wait tracker, deleting the "creator returns to invisible walls until someone hand-nudges each one" class: (1) the report clock FREEZES while the renderer is paused (BespokeGeometryWaitTracker.pause()/resume() on the visibility edges + the boot-hidden path) — throttled-flow browsers keep delivering ~1Hz hidden rAF ticks through the mesh handler's collect (which runs above the present gate), so the 10-attempt/~3-minute report budget burned into frames nobody drew while the sim worker was itself throttled too slow to heal anything; hiding pauses the clock, never rewinds it (the frame-budget guard's law). (2) Renderer-pause EXIT (tab return) is a re-arm edge: one unpark-all sweep gives every parked wait — budget-spent and sim-verdict alike — a fresh report budget, because a hidden window is exactly when reworks land unobserved (a plausibly-changed cause, the tracker's own doctrine); a wrongly re-armed unhealable wait re-parks within ~one report cycle (ecs-sync re-classifies per report and re-sends the verdict at-least-once). (3) The parked population gets a getLogs face: a park episode raises one aggregated bespoke-geometry-parked diagnostic ("N generated mesh(es) are parked undelivered on this player's screen", full roster in data) — previously the budget-exhaustion park was console-only on the affected player's machine, reachable solely by per-entity identify_object interrogation, which is why it read as "the stale copy is served from spawn's side".
  • An accepted god-wire spawn's live half is completed at the fold seam (dig 2431 — the #12074 mirror; 7 field specimens plus the multiwriter net's catch). The container room folds an accepted forwarded SpecMutations batch doc-only — foldSpecMutationsIntoWorld advances and persists the doc, deliberately running no applySpec, because the contract assumes the sender's simulating world uploads the created entities via StateDeltas. A thin wire client (anything that writes the wire without a simulating world — the harness gods, and any thin sender) never does, and every applySpec trigger downstream is foreign-gated (kiln skips echo-poking the author room, the self-echo stamp guard skips consecutive self-persists, and every doc-level compare is structurally blind to world↔doc divergence since the fold already advanced the doc) — so a doc-present/world-missing object stayed invisible to every joiner until the next foreign-authored write or a room recycle, unboundedly when neither came. Mirroring reapForwardedDestroyTargets ("the seam that authorized the destroy finishes it", row 2005/#12074): after fold+persist, every accepted spawn still missing a live entity after the same-flush grace (SPEC_MUTATION_SOURCE_GRACE_TICKS — a real sender's create row lands at preUpdate, before the −150 ingest, so real clients no-op) is BUILT by the server via a scheduled real applySpec of the folded head, whose reconcile spawns every doc-present/world-missing row — the exact cascade a foreign write used to deliver by accident. Idempotent both directions: entity exists → the hold resolves silently; server builds first → a late create row lands in the existing create-collision class (owner-idempotent update / host reparent). The invariant restored: a version the room stamps as self-authored is fully represented in its world.
  • draggable="false" no longer reads as an interactive surface (ledger 2527 — Idle Homestead's dead desktop clicks on 5.2.30/.31). INTERACTIVE_SELECTORS matched bare [draggable] by attribute presence, so the ubiquitous anti-drag idiom on sprite/cursor images — an element declaring itself LESS interactive — entered the pointer-events grant walk. Since row 2437's whole-body 500ms rescue cadence (tap-actions INTERACTIVE_GRANT_SCAN_INTERVAL_MS) that walk reaches body-mounted elements, and makeElementInteractive stomped the game's mouse-tracking cursor sprite (<img draggable=false style="pointer-events:none"> on document.body) to pointer-events:auto — an interceptor glued under the pointer hotspot by construction, eating EVERY mouse press in the game (authored buttons and world taps both) while HUD, state, frames, and the control grant all stayed healthy. The selector is now [draggable="true"] — the only drag-positive form of the enumerated attribute, and already the engine's own vocabulary (frame/styles.ts exempts exactly img[draggable="true"] from -webkit-user-drag:none). Drag-interactive elements keep their grant; anti-drag sprites keep their authored pointer-events. The dead-link dim inherits the same narrowing (cursor sprites no longer dim as "controls").
  • A model's death certificate must be earned (ledger 2395, dig abb254af — iz's wardrobe imported clean in 21s and was SERVING while the session read it terminally dead). The model lane minted terminal verdicts off ONE read-side transient: a single 502-without-Retry-After or a lone dead 4xx flipped the placard to "dead" (modelLoadVerdict) and put the asset in the eye's failed bucket, whose caption dressed it as "a receipted death, not a guess" — the confession instrument overclaiming (ledger 2306's verdict-honesty family, inverted face). The retry ladder was already the N-spaced-re-probes machinery; the defect was verdicts minted at attempt 1. Now "dead" requires earned evidence: the server's own fingerprinted terminal cook-kill tombstone (terminalParked), or a dead 4xx that answered EVERY spaced re-probe across the give-up window (deadParked — 10 dead answers over ~5 minutes). Anything short of that stays "waiting": the ladder keeps probing and a serving asset heals it on the next read. The unready atom names its evidence class (failedFact: "server-verdict" | "read-failure", plus failedAttempts — the measured N), and the scene-view note tells only what was measured: read-failure atoms get "could not be READ from the asset host — N reads have failed so far: a measured fetch failure, NOT the server's verdict on the asset … do NOT regenerate or rename", while the receipted-death/rename teaching is reserved for server-verdict atoms. Statuses are unchanged (no settle-gate shifts); the 2306 temporary split (retryInMs) is untouched. The attempt-5 retry-ladder diagnostic rides the same honesty (codex review): at that point no verdict can be earned, so it now teaches a measured read failure — loading placeholder, engine keeps retrying, "do NOT regenerate or rename it off this alone" — instead of claiming a "Couldn't spawn" placard and prescribing regeneration against the scene note's own words. When the read-failure bucket holds more than one asset, each name carries its own measured failed-read count (codex review: one shared maximum was a false sentence for every asset below it).
  • Caster/mask model WGSL compiles on the gpu's latched survivor arm (dig row 2560 — filing d5efc458, app d8a0fb4a on 5.2.31). getModelShadowPipeline keyed models/shadow/static/depth16unorm/spull and declared the storage arm's layouts, but built the WGSL with the builder's DEFAULT vertex arm — which consumes @location(6) lumeSurvivorAttr: u32 with no attribute behind it. The GPU refuses the pipeline (WebKit: "vertex descriptor creation failed !matchesFormat(attribute(6)…)"), three attempts park it, and every static-model caster draw on a convicted device silently skips — shadows from unskinned GLB models missing while everything else renders. Only static-kind model content on the storage arm hits it (convicted WebKit/AGX devices with indirect-first-instance granted); getModelMaskPipeline carried the identical drift (no production caller today; fixed for consistency). Same commit fixes the draw-side twins that invalidated the encoder instead (rigid-child caster binding + identitySurvivorBuffer gaining STORAGE usage). Red-first class pin: survivor-arm.test.ts — for every pipeline the models family registers (3 kinds × color/caster/mask, both arms), every @location the compiled WGSL consumes must be provided by the descriptor's vertex layouts with a matching format class.
  • No-op spec patches never enter the persist pipe (ledger 2551). Every patch-class spec verb already computed a no-op verdict (specDocChanged / the effective-view compare) and used it to fire specPatchNoOpWarning — but called recordMutation unconditionally, so a value-identical patchTerrain (the specimen: a behavior re-patching terrain to its current value, ~1 row/24s for 8 hours, app 3ad02cb1) rode relay-spec-save into a kiln version mint whose only diff was the __appliedSpecSaveRowIds ring: version history churned for hours and Savi's eye tools went EYE-STALE seconds after any read. The verdict now gates the recorder at every site whose verdict baseline IS the durable fold's baseline — applyTerrainSpecWrite (patchTerrain + the material/mark/structure verbs), patchPlayer, patchCamera, patchInputs, patchGodMode, patchUi, patchEngine, patchRouting, updateBuyable, updateJob, updatePlace (including the keyed-map objects branch, which now skips value-identical row upserts exactly like the whole-list branch always did) — each counting through the ledger-1157 noteSuppressedNoopScriptMutation receipt. The warn still fires: the no-op is told, it just isn't persisted. patchAtmosphere stays deliberately UNGATED: its no-op verdict is judged on the session-merged view (base + overrides) while the durable fold applies the patch onto the head's base alone, so an override pre-populated by a behavior would make a tracked bake-in read as a no-op and starve spec.atmosphere forever; behaviors never reach its recorder (no behaviorPersistSourceId), so no tick-loop churn reopens, and the kiln route floors its value-identical mints on the correct baseline. The kiln half (relay spec-save route: mutations mode gets the no-op refusal mirroring doc mode's identicalToHead — an all-no-op batch answers applied: 0 with no ring append and no mint, while one real change in a batch of no-ops still mints once) ships with the next web promote, giving older engine bundles the same floor server-side.
  • The applied-generation record captures its residency/instance keys at expansion time — place re-entry survives interleaved applies (ledger 2145 leg-1 cure defeat, dump a812611d — Alliance of Darkness's black-box prologue). #12599's cure broke the delta-0 binding at unload (place-cleanup drops TomeAppliedGenerationResource), but any later apply re-records it — and the record's top-level residencyKey/instancePlacesKey were recomputed at RECORD-WRITE time while every other capture (the derive-gate inputs, the record's own expansion field) reads expansion time. A hook running inside that apply — onSpawn → api.enterPlace, the prologue session-script idiom routing every joiner — marks the unloaded place resident AFTER expansion skipped it, so the completed apply stamped a key claiming a place it never expanded: the parked placeResident re-apply (the re-entry's only materialization trigger) then hit the delta-0 short-circuit and the place came back permanently empty — residency marked, api.spawn landing without a not-resident warn, authored skeleton/state defaults/onSpawn all absent. The record now stores expansionResidencyKey/expansionInstancePlacesKey: mid-apply residency drift reads as a delta-0 veto, the drain full-passes, and the reconcile's missing-entity spawn materializes exactly what expansion skipped. A re-entry that accepts a traveler materializes the place. Pinned red-first in place-reentry-interleaved-apply.test.ts (the mid-apply defeat shape plus the unload→interleave→re-enter orderings).
  • Render frame-rate cap primitive (creator ask r-8691b70e — Sangre y Vitral on an Adreno 619 at ~82ms/frame). engine.graphics.maxFps (integer 5..240, live on patch, null clears) is a ceiling the renderer paces presents to: a deadline pacer (renderer/frame-rate-cap.ts) gates the frame body exactly like the governor's 120 Hz half-rate pacing — steady spacing by schedule-anchored deadlines, stall re-anchor instead of catch-up bursts, sim tick untouched (determinism untouched). It min-composes with the player's own explicit cap (new resolved player-settings lane perf.maxFps, sanitizer-clamped 5..240; boot seed on the renderer init's playerPerf snapshot, live via set-player-max-fps — players may always go lower, never higher), and the quality governor's judged refresh target folds the same cap (setFrameRateCap, the maxRefreshTargetHz min): a 30-capped session is budgeted against 33.3ms — quality sheds only when the device can't make the CAPPED rate, up-probes stay live, and the capped cadence can never read as the environmental-throttle signature. Spec rail is the renderResolution grammar end-to-end: zod contract in tome-schemas, spec-sync → draw/frame-rate-cap client-plane singleton → renderer handler; the in-game FPS-slider menu itself stays the creator's script — the primitive is the whole engine surface.
  • The synthetic-transform ordered rebuild buckets by depth (#13264 — the streaming-load sort term). getOrderedRecords re-sorted ALL records whenever orderDirty, with recordDepth walking the parent chain inside the sort comparator — O(N log N × depth) per streaming frame (504ms of an 11.2s load capture vs 39ms settled, renderer worker). Depth is bounded by MAX_PARENT_DEPTH, so the rebuild now buckets records by one O(depth) walk each — no comparator at all — and the ingest touched-sort memoizes depth into a scratch map before sorting. Ordering is unchanged: stable within a depth by map iteration order, parents still before children. Bench (scripts/bench-synthetic-order-sort.ts, 10k records in chains 3-5 deep, 120Hz frames): dirty-sort term 5.68 → 0.51 ms/frame; the settled path is untouched (early return unchanged).
  • The HUD stale-compile heal and its receipt key on the whole require() closure, not the entry source alone (ledger 2426 face 3 — the lib-dep hot-reload miss). Row 2186's heal re-keys the game HUD's compile slot on the live doc's ui.render source and recompiles when the serving graph's uiSource lags it — but a save to a scripts/lib/*.js module the entry requires moves NO byte of the entry, so under a stalled apply lane the guard read converged, the compile receipt pinned sinceMs at mount asserting health, and the HUD deduped the old panel forever (Apartment 6B 26d489f5: three saves to scripts/lib/ui-directory.js under an unchanged ui.js, 52k+ dedups on one frozen sig, compile ok). CompiledSpec now stamps uiDependencySignature (the tracked dep closure's per-source signature, from the same dependency sets the compile already records — a Map hit, never a re-walk), the controller compares it beside uiSource and heals through the same content-keyed cache, and the receipt's sourceHash folds the dep signature in so sinceMs re-stamps on lib edits too — the receipt can no longer assert health while a dep-shaped edit serves stale. Graphs minted before the field existed read as "unknown — never heal on deps alone", so no healthy mount confesses a stall that never was. Pinned red-first in worker-controller.test.ts (the lib-dep stall shape plus the pre-field no-spurious-heal control).

Engine v5.2.31

Released August 20, 2026

  • Characters stay seated — riding a vehicle no longer forces the walk animation. A character parented to a moving vehicle or platform no longer plays walk/run at the vehicle's speed, and npc: { locomotion: false } now releases the player avatar's engine gait too, so a seat clip plays alone.
  • Your character appears even where animations can't bake — static beats invisible. Where the animation service isn't deployed (local dev), characters load as their unanimated base model instead of holding the placeholder orb forever.
  • Savi can no longer mistake a frozen view for your live screen — she says when her eyes are stale instead of guessing from old pixels. When she captures a player's screen and that player's tab has silently stopped receiving updates, she now gets told the frame is stale instead of treating old pixels as the live game.
  • Buttons and clickables work with the mouse again on desktop. 5.2.30's touch-tap fix was classifying desktop clicks as UI wherever any script-wired or cursor-styled element sat — in click-driven games, every mouse press could read as a UI hit and world clicks went dead. Desktop clicks now classify exactly as they did in 5.2.21, while phones keep all of 5.2.30's touch fixes (script-wired buttons and pads still take the tap over the engine stick).
  • Objects keep their rotation while you move them. Rotating an object and then dragging it somewhere no longer snaps the rotation back mid-drag — a background save arriving while your hand is on the object can't undo what you just did.
  • Rapid spawn-and-despawn effects now cost the connection nothing while it is catching up. Entities born and destroyed before the room ever heard about them simply never go on the wire — the room's journal stays clean and real updates (movement, commands, host claims) keep flowing at full rate under any spawner load.
  • Your game can react when the mouse cursor is freed — pause menus on first Esc now work. The browser eats the Esc that exits mouse capture, so scripts could never see it; now input.actions.pointerLockLost fires the frame the lock releases and input.axes.pointerLocked reads 1 while the crosshair owns the aim — no declarations needed.
  • Your edits always land in YOUR room — never an invisible copy — even right after joining. A background sim session could briefly win control of the area you were standing in (especially during busy building sessions or right after you joined), and Savi's builds would land in a copy of the world you couldn't see. Now a real player standing in an area always outranks the background session, so what Savi builds is what you watch appear.
  • Long music tracks in multiplayer rooms no longer cut out after a second — the song keeps playing through world syncs and rematches.
  • Worlds with rapid spawn-and-despawn effects no longer flood the connection — everyone's movement stays smooth. Spawners that mint and destroy entities continuously used to fill the room's journal with bookkeeping and could starve real updates (frozen movers, "like I disconnected without disconnecting"); their churn now rides the lightweight restatement lane and dead spawns fold away under pressure.
  • 2D characters no longer flash white when switching animations — they hold their pose while the next animation loads. The spinning loading rings now appear only the very first time a sprite paints, when there is nothing else to show.
  • Texture scripts no longer break image loading — script-driven textures and image uploads work together. A game whose art is drawn by texture scripts (scripts/tex-*.js) no longer collects false "FAILED to generate — rename it" verdicts against healthy scripts, and animated script textures stop getting fake frame grids guessed at them.
  • State handles never go stale — edits after a state change always land. A script can grab its state once and keep using it: reads see every later patch the moment it happens, and writes always reach the world, even right after a respawn-style state reset.
  • Big built-out worlds run much smoother while you build. A background cleanup check used to re-scan every object in the world every tick — in decorated worlds that alone could eat most of the frame budget. It now only wakes up when something actually changes hands.
  • Scripted movers keep moving in long-running multiplayer worlds. Anything animated with Math.sin/Math.cos of game time — patrolling animals, bobbing platforms, spinners — used to freeze in place in multiplayer rooms (moving only once every few minutes) once the room clock got large. They move continuously now.

technical notes

  • The a3dc gait shim honors the seat contract (census 449f52a6 — kevin's walk-while-driving). Animated3DCharacterLocomotionFeature — the gait writer for every default player avatar — measured speed from world position deltas, so a rider parented to a moving hull (whose WorldFeetPosition hierarchy-solve rewrites from the hull every tick) read as running and got weight-1 walk/run channels rewritten against the script's seat clip; and it never read properties.npc.locomotion, so the documented seated-character switch worked on npc-loco bodies but not the player's own avatar. Now a parented entity gaits from its LOCAL feet-position delta (a mounted seat is stationary; a walk across a moving deck gaits as a walk — moving platforms and elevators come along free), the world-frame PhysicsBodyState read is skipped while parented, mount/dismount/re-parent frame changes forget prev-pos (no phantom sprint — the frame is the parent id, so a seat swap between two mounts forgets too), and locomotion: false releases the shim's idle/walk/run channels once and stops all of its writes — facing included — until re-enabled. The channel release is the shared releaseA3dcLocomotionChannels, one home with the model-write retire path.
  • The renderer serves the base model where animated variants can't bake (kevin's local-dev orb, thread 1787165428). In an environment with no ardy motion transport, kiln answers every ?animations= cook with the structured config-absent 503 (base-flow ARDY_UNCONFIGURED_*, Retry-After 1h) — and the renderer parked the URL on that verdict, so the character never resolved: orb placeholder forever, in an environment where the base model serves fine. The 503 body now carries a machine-readable class (reason: animated_variant_unavailable, kiln ARDY_UNCONFIGURED_REASON); the engine's probe reads reason off 5xx bodies onto MagicCdnFetchError.rejectionReason and replays it through the verdict tombstone (a second consumer of the same URL classifies with zero network); and the renderer's model load, on exactly that class (isAnimatedVariantUnavailable — the structured reason, never the teaching prose), retries the same URL stripped of ?animations= with one honest warn line. Scope pinned by negative controls: an ordinary 503 keeps today's ladder, a dead 404 on the base model still fails honestly (one fallback attempt, no loop), and cold-cook 202s (the 2320 family) never trigger the fallback. Prod (variant available) is untouched — the fallback only exists behind a verdict prod never mints.
  • Client-sight frame answers carry the room-feed freshness stamp (verdict d29f361d — the client-sight EYE-STALE hole). Scene-view already stamps every capture with the room-feed fact (ledger 2266; appliedSpecVersion since 2337), but the client-sight lane discarded it at the capture provider and ClientSightAnswer had no field to carry it — so the EYE-STALE belts that protect look/identify could not cover capture_client_frame, and an attached-but-frozen feed answered frozen pixels captioned "THEIR actual screen" with a fresh capturedAtMs. The stamp now rides the whole lane: ClientSightViewportCapture.feed → the main→worker hop outcome → ClientSightFramePayload.feed, sanitized at the wire door like every other untrusted field (a malformed stamp drops to the old-engine shape, never poisons the frame). The tool side (cf-studio-chat) refuses a stale-stamped frame with the eye_stale verdict — the transport face (feed state ≠ attached, with its age) and the version face (applied spec version lagging the doc head past the fan-out beat) — exactly the gates look and identify already run. Stamp absent (engines predating this) keeps the old behavior byte-identical.
  • The row-2437 tap rescue is touch-only, and the interactive grammar never adopts a plane (row 2527 — msweep4 verdict 397ec807, ejthebae's Idle Homestead; his own A/B: same head spec, 5.2.21 works / 5.2.30 dead). 2437's five legs widened the pointerdown overUi verdict geometrically so the touch arbiter yields over authored controls — but the same overUi bit rides to the mouse lane, where it means "eat the world press" (raw-capture pressMouseButtonIntent returns on overUi before the ring append; the worker PointerState marks the press UI), and a desktop mouse has no claim bands to yield: the widening bought desktop nothing and killed pointer-only games' click lanes wherever any authored-grammar rect sat. Container-shaped matches made it total: a delegation wrapper (wrapper.onclick = fn), a cursor:pointer scaffold, or onMount delegation on the viewport-spanning game mount matched the widened grammar at plane scale, so every desktop press in the game read overUi:true — and the grant cadence then healed such wrappers to pointer-events:auto, turning them into native full-screen UI hits that ate the world lane for every pointer type. Three cuts: (1) input-forward's geometric DOWN widening applies to pointerType === "touch" only — mouse/pen presses keep the target-based verdict (5.2.21's classification, byte-identical); (2) tap-actions' fall-through adoption is touch-only (the rescue compensates the touch arbiter's theft and user-agent cancel claims — diseases a mouse never catches; script-wired controls still become mouse-clickable through the grant cadence), and the adoption heal skips delegation containers; (3) the grammar/grant walk never adopts a plane — engine UI planes (data-input-capture="ignore") are never authored controls, and the script-wired grant forms (handler properties, inline cursor:pointer, self-opts — the forms 2437 added) grant leaves only: a script-wired element containing other authored controls is a delegation container, its real controls are granted individually, and clicks still bubble to the delegated handler. Attribute-form grants (INTERACTIVE_SELECTORS) keep their since-forever behavior. All of 2437's own pins stay green: touch adoption, the iOS pointercancel verdict, the grant cadence, the arbiter yield over authored touch surfaces, and bare-canvas presses staying the movement stick's.
  • A spec apply never stomps the spatial keys of a gesture-held entity (iz's rotate-then-move revert, thread 1786720296.462559, round 3). The interpreter's updateObject re-asserted authored feetPosition/rotation/scale over a live god-mode drag: a REPLACE apply unconditionally (rotationSpecChanged = replaceRestore || …), a normal apply whenever the incoming row differed from the applied generation. On a singleplayer client every other protection is structurally off at once — the ObjectAPI hold guard fires only on multiplayer SERVER worlds (gesture-hold.ts), the pending-edit overlay and flush-confirm rail arm only in room-runtime (the client is not the persist authority), and spec-sync's local-sim write mask is explicitly !isSingleplayerWorld — so an incoming apply landing inside the move-drag window (GOD_MODE_PERSIST_IDLE_SECONDS = 1 puts the rotate's persist round-trip exactly there) snapped the just-committed rotation back mid-drag; the drag closure re-writes feet every frame, so ONLY rotation visibly reverted. Now updateObject consults the gesture hold (resolveRefusingGodModeGestureHold, gated by an anyGodModeGestureHeld whole-world probe so gesture-free applies pay one store lookup) before the three authored-transform writes, on every world: the LIVE write is skipped while the hand holds the entity, the doc keeps the incoming value, the release commit persists the hand's pose for the keys the gesture owns, and the gesture's own updateObject calls (spline preview re-loft) pass through the withGodModeGestureWriter bracket. Pinned by rotate-then-move-apply-stomp.test.ts — replace and non-replace stomp lanes red pre-fix (180→90 and 180→45), with a non-held sibling restoring in the same apply and the post-release replace reset (ledger #296) as controls.
  • The fresh-key annihilation fold (row 2493, the W5 wall's named follow-up — composes with spawner-churn-rebirth-lane-and-tomb-fold). The tomb-prefix fold left one standing tomb per dead cycle in the un-acked outbox, so a sustained fresh-key spawner still accumulated unfoldable entries toward the cap. foldOutbox now ANNIHILATES a whole lifecycle: a CREATE and its own DESTROY both still inside the un-acked window bracket an entity the wire never saw — nothing of it acked, so nothing of it journaled under this client's msgIds that a welcome retransmit could resurrect — and the birth, every intermediate row, and the death fold to NOTHING. Never across an ack boundary: the candidate test requires the entity's oldest in-window row to be its own create (e:{id}:@c), so an acked/departed birth keeps the destroy traveling (the wire saw the birth; it must see the death — the minimal tomb, with intermediates still folding under it). Whole-entry honesty runs to a fixed point: a family annihilates only while every entry carrying one of its rows folds entirely, so a family row stranded in a surviving batch keeps its tomb. Keyless barriers (commands, events, member-avatar rows) still confine everything to one segment — annihilation can never un-birth an entity a command was enqueued beside, and membership truth is structurally untouchable (member rows are keyless by the fold constitution). Rows that never journal never earn restatement promotion either — the flood dies at the source. Named residual: un-acked is not un-sent, so a create journaled with its ack in flight at socket death can lose its annihilated tomb; the orphan is a keyable non-member entity, exactly the class world-sync's liveIds sweep reconciles — the same crashed-author residual motion's restatement contract accepts.
  • Pointer-lock loss is visible to behaviors (r-19126e01 — iz's Duskfall, savi platform-gap fceba168). The browser exits pointer lock on Escape and swallows the keydown, so the loss never reached gameplay code: PointerStateResource.isLocked was engine-internal, raw-capture handled pointerlockchange without synthesizing anything script-facing, and setCamera({pointerLock}) was a write-only lever — "first Esc opens the pause menu" was unauthorable (players pressed Esc twice). Two engine-injected primitives now ride the existing input lanes, no new wire vocabulary: the pointerLocked axis (1 in every sampled frame while the aim is the crosshair — the browser lock, or the touch aim pin; absent unlocked, where the input proxy reads 0) and the pointerLockLost action (pulses in the first frame sampled after the lock releases — every exit: Esc, releasePointer(), focus loss, cursor-needing UI). Both are injected by input resolution's buildFrame from the same pointer-resource read that already gates pointer coordinates off locked frames, so state, transition, and coordinates can never disagree within a frame; the pulse rides the frame's actions, so client dispatch, the server's permissive action lane, and client-auth resim all see it identically. Uncontrolled samples consume the edge (a loss with nobody to deliver to never synthesizes a stale pulse later). pointerLocked joins ENGINE_INTERNAL_AXES (mod-alias stripping, undefined-input notifier exemption), pointerLockLost joins ENGINE_ACTIONS.
  • The place election's null-rank window is closed: an in-place real player outranks the headless builder shell even while its folded rank is null (ledger 2525 — the 2464-family's #13176 survivor; dump 4edec9f7). The #13176 cure ranked shells behind resident players by comparing rank streaks, so it never fired when the player carried rank:null — a folded hidden edge whose journaled visible heal was lagging (under the 2524-adjacent journal flood, for minutes) — and the shell's stale streak won the claimant tier unopposed. Exec routing follows the seat, so Savi's run_script landed on the shell's headless sim: 25-minute shell-held windows, builds into a world the creator could not see, four field incidents in one jam day. Now electWinner carries a PERSONHOOD tier between ranked residents and claimants: a real (non-standing-session), host-eligible resident wins the place over every claimant regardless of rank — the rank is a tiebreak among players, never a gate on personhood. The tier is judged by the same connectivity/tick overlays, and the tick gate is the working arbiter for the genuinely parked tab: beats are EPHEMERAL and never journaled, so they stay truthful when the fold lags — an audibly ticking resident holds the seat, one that decays past the 15s liveness window yields the place to the shell exactly as before, and its returning beat re-takes it. The shell still hosts every place EMPTY of real players (#13176's headless-builder case, pinned as the negative control). Room-truth mode closes the same window fold-purely (electTruthWinner personhood tier ranked by m:_ head seq; hasEligibleRealResident no longer requires a live rank, so an h:_ row assigning the shell over a rank-null resident degrades to the derived seat). Pinned by relay-place-elect-null-rank-window.test.ts end to end, exec lane included.
  • Relay music no longer dies on head re-delivery (row 2504 — lazydev's "music got killed in the first second", dump 05485279). Every relay delivery of the TomeMusicState head re-rebases layer.startTick into the receiver's clock (relay-music-lane.ts translateMusicEntry), so a re-delivered anchor (journal restatement, world-sync) or a fresh publish (rematch duck, volume ramp) shifts it by a few ticks of rebase jitter — and music-client.ts applyLayer keyed the layer instance off (epoch, clip, startTick), beyond the component's own documented contract. A shifted anchor read as a new song: the live track retired, a same-clip replacement minted under a new voice key. On the stream lane (clips priced over the 16 MiB decode budget — every long track) the replacement shares the clip's ONE HTMLAudioElement and MediaElementAudioSourceNode, so the old voice's vanished-key teardown paused, rewound, and disconnect()ed the voice that had just started — ~0.2 s of music, then silence while music.now() (tick-derived) kept saying playing. Two structural halves: (a) instance identity is (clip, epoch) only, honoring music-components.ts's contract — a startTick delta lands as position truth through the existing per-frame deriveSeekSec re-stamp, never a retire+mint; (b) stream elements transfer across voice keys the way same-key retrigger already transfers release — only the element's CURRENT owner may pause/rewind it on teardown, and a voice severs only its own edge out of the shared source node (disconnect(node) instead of bare disconnect()). Class bounds: relay multiplayer × stream-lane music; singleplayer never rebases and buffered voices own independent source nodes (their suites unchanged-green). Kin: 2061 (relayAnchor), 2131 (relay free-run ticks), 2183 (the decode-budget size rail).
  • Fresh-key spawner churn stops re-earning the restatement lane (row 2493 — the idle-zoo journal flood, tide's read after review 12302a2f disproved the juice hypothesis). The earned-restatement detector keys streaks per (entity, component), so a spawner minting short-lived entities at fresh keys re-paid the earn tax on every cycle — 2 journal rows per component per rebirth plus the create and the tomb, forever (~196k journal rows in one idle zoo session; the wall-5 saturation sawtooth that starved place claims and movers). Now a lane that died with a BORN-YOUNG entity (created and deleted inside RELAY_CHURN_REBIRTH_WINDOW_TICKS, 300 publish ticks) hands off by component name: the next entity CREATED inside the window inherits the earned lane on its first write. Both ends must be newborn — an entity of unknown or old birth re-earns from scratch (one-shot immunity holds for bystanders and organic lives), and only EARNED streaks (≥ RELAY_CHURN_PROMOTE_STREAK) hand off, never partials. Durability is unchanged in kind: inherited writes ride the same EPHEMERAL lane whose keyed create carries initial values and whose rest stamp journals final values on quiescence. Harness meter: a steady-state single-component cycle journals 2 rows (create + tomb), down from 4.
  • The wall-5 fold learns the tomb's own reach (tide's named backstop). foldOutbox folded only same-key rows, so fresh-key churn — where no two cycles ever share a key — could never fold and pinned the un-acked outbox at the cap, closing admission on every real journal-bound publish. Now an older keyed row folds under a NEWER tomb whose prefix covers its key: exactly the supersession the relay itself applies when the tomb lands (journal.ts's TEXT-range prefix retire), one hop earlier. The tomb itself NEVER folds (every observer still learns the death; peers that never saw the folded create skip the unknown-id delete by design), keyless barriers still stop the fold, retag carriers still never fold, and a create's scrub prefix deliberately does NOT supersede — folding a despawn under a same-id rebirth would hide the death from live peers holding the first life (create-apply is an upsert, not a reset). Harness meter: 150 fresh-key spawn+despawn cycles under a black-holed uplink leave 177 outbox frames (one tomb per death + live world) instead of saturating at 256 with admission closed.
  • Cold-atlas sprite swaps hold the last-good frame on every swap path — the loading rings render only where there is no prior frame to hold (seele's Ironbloom "flashy white", ravi's tape r-7246111b shape 1). A sprite quad already showing real texels must never route to the >2s pending-texture placeholder (the bright spinning rings, lume/sprites/shader.ts) when its clip/facing swap points at a still-cooking atlas — with gen-on-demand cooks the mixer multiplies the exposure (4 facings × 5 clips per unit kind). The draw/sprite path already held (the mid-fetch hold in applyComposedSprite — in every 5.2 engine since the lume port; ledger 1817 upgraded it on 07-28 from holding the texture atom to the WHOLE tuple, and either form keeps the lease bound, so no rings) and is now pinned by a mixer-shaped regression test (resolved-overlay ?animated= swap past the anti-flash window: held batch keeps resolvedMix 1, zero placeholder census). The hole was draw/sprite-instances: a swarm texture swap re-keyed every member onto a lease-less batch immediately, flashing the rings over quads that were already painted. applyInstanceSpriteSwap now gives the instances path the same hold — showing-resolved + cold target ⇒ keep the ENTIRE last-good draw value, subscribe once, apply the latest driver value whole when the texture lands; a swap reverted while cooking abandons its subscription so the late arrival can't flip the swarm. First paints never enter the hold (no lease to hold), so the loading affordance and the 2s SPRITE_PLACEHOLDER_ANTI_FLASH_MS window are byte-identical for genuinely new quads (sprites-store.test.ts anti-flash test unchanged).
  • Texture-script refs never hydrate as sprite atlases (ledger 2520 — Route Zero, bran's image-load filing r-86d3cafc). A scripts/tex-*.js ref's frame/fps/clip truth is the script's own sheet declaration, pushed by the renderer bake (assets.scriptedSpriteAtlas) — there is no file to probe, so every probe fired at one could only fail, and its terminal report taught "regenerate under a different name", which mints a fresh scripts/ name that fails again (Route Zero climbed to ~60 dead names following that advice). The sim's atlas hydration door (SpriteAnimationFeature.ensureSpriteAtlasHydration — the intent lane behind sprite.animation, the frame-event dispatch, and both mixer lanes) now carries the same isTextureScriptRef guard its two siblings already had (sprite-metadata-hydration, the renderer worker's hydration door — the latter pinned by test, unchanged). The guard also kills the smart-inference fallback faking a 4×4/16-frame grid over baked texels for scripts/x.js?…&animated= refs (every mixer targetUrl composes that shape). Both resolveMixerClipTiming twins (client feature + getChannel/ObjectAPI) answer the honest nominal fallback for script-ref sprites instead of a fake-grid clock, a null-forever timing wedge, or the getChannel twin's doomed raw assetFetch that 404'd and latched failedAt for the whole session. The rename advice in the terminal atlas report is now unreachable for scripts/ refs (it remains correct for real magic-cdn names). Latch state was in-memory per worker session, so already-latched rows clear on the next reload/engine pin — no manual clear needed.
  • State handles are forever-live — one entity, one proxy (ravi's kitty read, report 92a51b07 §3). The tracked-state membrane cached its proxy BY STORED BAG, so every whole-bag replacement — patchState's merge (state-merge.ts mints a new object), replaceState/setState, the changes-nothing gate's neighbors, replication ingest — silently froze every handle a script already held: reads served pre-patch values forever, writes no-opped (the old markUpdated closure checked current === state). Savi's Let's Go Kitty double-death was this bug (the revive patch's own s.invT read answered 0 on the very tick the mercy window was written; 73 edits never caught it), and the corpus models the const s = getState() top-grab 38 times. Now the cache is keyed PER ENTITY and the root proxy's traps resolve the CURRENT stored bag at trap time (a store-writeVersion stamp keeps read loops on a one-integer-compare fast path; overlay worlds resolve honestly per trap since staged writes never move the base store). Nested writes into containers that survive a merge also replicate now (the mark is no longer gated on the dead root bag). Terminal edge preserved: a despawned entity's handle freezes on its last bag. Bench (interleaved A/B medians, TRACKED_STATE_BENCH=1): getState() 179→159 ns (−11%), root write 200→192 ns (−4%), root read 70.7→74.6 ns (+3.8ns — the stamp check), nested read 115→125 ns. Pinned by tracked-state-forever-live.test.ts (double-death repro, replaceState/ingest faces, suppression-gate and identity negative controls).
  • Client-realm scope reap is edge-driven instead of an every-tick world sweep (row 2537, leo's 470ms/tick sim). tome/client-realm-scope-reap used to walk every (TomeRealm, TomeSpawnedBy) row per tick and ask the authority-verdict cache — which clears on every epoch bump, i.e. on ANY spawn/despawn — so a 13k-entity world under active building cold-rewalked the whole ownership graph every tick (470ms of a 650-976ms sim). Scope exit has exactly three edges (wake handback, host revoke, owner AOI exit) plus birth, and every one is observable at write time: candidates now index themselves under every id their verdict walks through (spawner chain + link controllers), coordination-table writes (TomeSuspendedSessions/TomePlaceHosts/TomeSimLeases) re-judge all candidates, a despawn/spawn or authority-component write on a chain key re-judges exactly its dependents, and the non-component inputs (control set, local player/session, hosted-places identity) are O(1) compares per tick. clientSpawn materializations and god-mode tooling (direct tome/client-spawn root — a depth-1 permanent in-scope verdict) never enter the index at all. Verdicts are unchanged: dirty candidates are judged by the same isClientAuthLocallySimulatedEntity, so the ORBIT-17 orphan class stays dead. Bench (scripts/bench-scope-reap.ts, 10k static decor + 32 candidates under per-tick spawn/despawn churn): mean 8.20ms → 0.004ms per tick; at 13k, 10.74ms → 0.003ms.
  • Verdict-close redial backoff (dig 31760db2 — the leo-class wedge loop's join-rate lever). The authenticated attach resets reconnectAttempts (the ledger-716 budget reset), so a session that attached and then died to one of the client's own self-heal verdicts — 4435 silence-watchdog, 4439 redial-fresh/ingress-overflow, 4440 upload ack-silence — redialed at the flat 1s base delay forever; the A1 escalation (ledger 2198) covers only pre-attach bootstrap failures. ClientRoomRuntime now keeps a short-lived-session streak: an authenticated session that dies to a verdict close while younger than VERDICT_CLOSE_SHORT_SESSION_WINDOW_MS (30s; the dig's wedge cycles measured openForMs≈11s) increments it, and scheduleReconnect floors the ladder's exponent on the streak with failRelayJoin's exact arithmetic (base 1000ms × 1.5^streak, capped at RELAY_JOIN_FAILURE_BACKOFF_ATTEMPT_CAP ≈ 25.6s, ±20% jitter) — downstream of the attach reset and the watchdog's fresh-budget reset, so neither flattens a wedge's cadence. A session that survives past the window resets the streak; a new room starts clean. Healthy paths are untouched: a one-off half-open condemn is a streak of 1, exponent 0, base delay. Expected effect under a wedge: the redial cadence walks 1s → 25.6s instead of holding flat — ~10-25x fewer joins against the same wedged room.
  • The sandbox Math shim never quantizes ARGUMENTS — results-only f32 (tide's dig + fix 92a40e5bde; ledger 2317/2319's third face: the relay pen freeze, thread 1787035501). Math.sin(t * speed) with t from the relay tier's epoch-anchored room tick puts the argument near 2^35, where f32's ulp is 4096 radians — the old quantize-the-argument wrapper made sin a piecewise-constant plateau (~205s per value at speed 20). The script then wrote the SAME value every tick, same-value ECS writes are silent no-ops, and the zoo's kinematic movers froze on every client, twitching once per ulp crossing (witness v3 bit-exact closure: run-2 held y=2.974238872528076, stepped to 2.5244932174682617 at scriptTick 53615760384 — the model reproduces both bit-exactly, step time +48.2s predicted vs +47.96s measured). Arguments are f64 bit-identical cross-realm already (exact literals, correctly rounded native arithmetic, f32 replicated state, f32 shim outputs), so argument quantization bought zero determinism. Results of implementation-approximated members keep their f32 quantization; abs/sign/sqrt/max/min join the spec-exact native family floor/ceil/round/trunc already ride (ledger 2317).

Engine v5.2.30

Released August 19, 2026

  • Saving no longer freezes or respawns objects in multi-area singleplayer games. In a singleplayer game with multiple places, every save was silently destroying and respawning every object outside the player's current place — re-running spawn effects, resetting runtime state, and freezing the game for a moment while materials and geometry rebuilt. Saves are now quiet: nothing that didn't change is touched.
  • Script-wired buttons and touch pads now work on touch — no bridge or workaround needed, even with engine touch controls on. Controls you wire from a script (element.onclick = ..., pointer handlers, cursor:pointer styling) now take the touch everywhere they're visible — the engine's movement stick and camera no longer steal taps from them, and iOS taps that Safari tried to cancel still land. Empty screen space still belongs to the engine stick and camera.
  • No more "Connection lost. Reconnecting…" flashes when you tab back into your dev room. A hidden tab's queued background traffic now just drains on return instead of forcing a fresh reconnect and a full world reload — tabbing away and back is seamless again.
  • Place travel works reliably on busy rooms. On rooms with lots of join/leave churn, a player's enterPlace could silently do nothing — the call returned the destination but nobody moved — until the next clean rejoin. Travel now lands on the first try regardless of how churny the room was when you joined.
  • Sounds can now stick to the thing that made them: playSound(clip, { position, follow: true }) keeps a sound on a moving object instead of leaving it behind at the spot it started — your own footsteps, engine hums, and whooshes stay with you instead of trailing behind as you run.
  • Spatial audio now always hears from the camera you actually see. Games with custom mouse-orbit camera scripts could previously get subtly wrong or mirrored panning while everything looked perfect — that seam is closed.
  • Safari no longer eats all your memory while a game sits waiting for its first click — the bug that could freeze a whole laptop on a quiet world is gone.
  • The erase brush now erases every painted ground material at once — one pass returns the ground to its natural look instead of only removing the material you happen to have selected (paths and roads stay).
  • Material brushes have a new Replace mode next to Paint and Erase: painting in Replace makes your material win by pushing the other painted materials out from under the stroke, instead of blending in on top. Plain painting is unchanged — it still layers additively, and brush strength still decides how strongly you cover the world's natural ground.
  • Savi gets interrupted less while you build. Routine engine notices — a texture that didn't load, a clip name that doesn't exist, a placeholder holding while art generates — now go quietly into her logs instead of pinging her mid-conversation. She still hears immediately about real breakage (broken scripts, crashes, things that stop rendering), and about any problem that suddenly happens dozens of times at once.
  • Games with lots of moving objects spend less engine time per frame on internal bookkeeping — positions, rotations, and scales are read without generating garbage-collector pressure, leaving more headroom for game logic at the same object count.
<!-- falsifies: none. Every change is engine-internal: behavior scripts and run_script observe query results only through api.query / api.getSpec / ObjectAPI reads, which build their own fresh result objects at the boundary (verified: query-utils.ts buildQueryResult, the exec overlay's own row builder, and per-call world.get mints). No script-observable behavior differs, so no discriminating probe can exist. The section is omitted rather than emptied because the parser accepts only claim/probe items under a falsifies header. -->
  • The stray scrollbar that could pop over your game's right edge (and make your game UI look broken) while art was spawning is gone.
  • Editing your game's UI no longer gets stuck showing the old version until a restart. If the room's update lane ever stalls, the engine now notices within a few seconds that your HUD script changed, recompiles it from the live copy, and repaints — and tells Savi it happened instead of silently serving the old screen.
  • Savi stops getting blamed for axes the starter template ships. Every input change in a 3D game used to print five warnings telling her to remove lookX/aimYawSin-family entries that the platform itself put in the game — noise that could bury the real error when something was actually wrong. Those entries are now recognized as the platform's own, new games no longer include them, and the warning only fires for something that can genuinely break: binding a key or stick to an axis the engine computes from the camera.
  • Changing another object's state now just works everywhere you can see it: edit .state on anything a query or a collision hands you and the change is real — no more edits that silently vanish because they touched a copy.
  • Bot-heavy and query-heavy games get faster: reading state off query results no longer copies the whole state bag per object per tick, state writes do half the work they used to, and Savi's live edits over big worlds respond quicker.
  • Savi working in the background no longer takes over the area you're playing in. Her behind-the-scenes builder session used to be able to win control of the exact arena you were standing in — and it can't run physics, so bots froze, pickups never appeared, and "nothing works" until a restart. Now the player standing in a place always holds it; Savi's session only runs the places nobody is in.
  • Terrain works everywhere in multiplayer worlds — no more falling through far areas. Ground far from the players — under a driverless car, a wandering NPC, anything physics moves — is now guaranteed solid in multiplayer rooms, exactly like singleplayer. Before, objects in far regions of a shared world could fall through or let players walk through terrain that never got its collision built.
  • Placeholder images keep their shape while models generate — a tall character concept no longer renders squashed and stretched on its loading card.
  • The touch stick on phones no longer freezes and vanishes when you move your thumb slightly. In games with on-screen UI, the browser could steal a touch a few pixels into a drag (it read the drag as a page-scroll attempt), which killed the movement stick, camera drags, and hand-rolled touch pads mid-gesture. Touches on your game now belong to your game from press to release — and your scrollable panels and lists still scroll exactly as before.
  • Savi can rename something across your whole game in one step — and show you every change before making it. Renaming a town, an item, a character used to mean editing every script that mentions it, one file at a time. Now one call previews every affected line across every script, and the same call applies it.
  • Replace mode now does what its name says: painting in Replace pushes out the world's natural ground as well as other painted materials — a full-strength stroke leaves pure paint instead of a half-and-half blend. Erase still returns everything to natural, undo still takes the whole stroke back.
  • Ground material transitions render crisper everywhere: where two materials meet you see the dominant one with organic speckle instead of a translucent wash.
  • Games with lots of moving objects spend less time in memory cleanup — motion updates now recycle their bookkeeping instead of generating garbage every tick.
  • Editing large worlds is faster: applying a change to a world full of roads, power lines, fences, and rooms no longer re-derives every generated piece on every edit — an edit now costs work proportional to what actually changed (~4x faster applies on lowering-heavy worlds, and burst edits like big builder-script runs spend far less time in bookkeeping between steps).
  • Games with several AI-generated or uploaded 3D models hold dramatically less memory on phones — the engine now frees each model file's raw bytes once its textures reach the GPU instead of keeping whole files around, which is a big step against the "game reloads on iPhone" class of crash. Busy building sessions also idle lighter: landing model metadata no longer re-scans all your scripts, and big boot bursts no longer leave permanently enlarged buffers behind.
  • Long sessions in sound- and media-heavy games use much less memory: decoded sound effects, warmed video clips, and edited-away material shaders are now released when nothing is using them (they reload on demand), which especially helps phones stay alive in big games.
  • Games with lots of timers — every() loops, staged builds, scheduled waves — no longer pay a per-tick sweep over every armed timer, and the engine's own tick loop allocates far less, so long sessions hitch less from garbage-collection pauses.
  • Invisible rotated walls: static boxes spawned with a yaw could, around busy spawn bursts and reconnect churn, briefly (or in bad cases permanently) collide as if unrotated — cars slamming into walls that measurably weren't there. Static colliders now always converge to their authored rotation as soon as it arrives, no matter what order the network delivered the pieces in.
  • Terrain texture tiling now uses the same textureScale meaning as objects: meters each texture tile covers. If your world's ground suddenly looks ultra-dense, ask Savi to convert the old values (new = 2.5 / old).
  • Your character no longer keeps running on its own after opening menus mid-stride. If you were holding a movement key when the chat or an overlay opened, the release landed on the menu and the game never saw it — the character kept walking with no key down until a god-mode round trip cured it. Held keys now release the moment the game surface stops receiving your keys.
  • Mouse aim no longer goes dead after quickly closing chat — the game re-grabs the mouse itself. Before, flipping the chat overlay open and shut at the wrong instant could leave a first-person game with working keys but a dead mouse until you opened and closed chat again.
  • Your mouse's side buttons can now be bound — quick-melee, ping, whatever your game wants. Ask for mouse4 / mouse5 binds; the browser's back/forward navigation stays out of the way while a side button is bound.

technical notes

  • The singleplayer save destroy/respawn storm is gone: one spec-apply domain per world (dig 28aafd23, row 2445; engine-bug dump db83ebcd). The singleplayer glue mounts BOTH spec-apply lanes on the one authority world — tome/spec-sync (client, netIngest 20) and tome/spec-sync-server (simulation −950) dedup by name only. Per save (revision bump, zero content delta) the client lane applied a filterSpecForPlaced doc over a world that is the simulation authority for ALL places: every non-kept authored place's objects emptied, place instances expanded over the shrunken view to ~0 objects, and the reconcile diff destroyed the difference (302 destroyed in the dump — onDestroy hooks fired, spawn-owned children cascaded), then the server lane's whole-doc apply respawned it all (307) the same tick. The fix: when the doc being applied resolves singleplayer, the client lane applies the WHOLE merged doc and removes TomeUnfilteredSpecResource (its absence is the established "not place-filtered" contract — presence also keyed the effective-places expansion into emptying non-kept INSTANCE places, and spec-update/reset into re-filtering). Both lanes are now content-identical, so applySpec's delta-0 gate short-circuits whichever runs second — the population is written once and never fought over. Place travel on a singleplayer world stops tearing down the place left behind for the same reason (the authority world holds every place; rendering already place-scopes via the renderer's PlaceMembership include-filter). Multiplayer clients are byte-identical: the filter, the unfiltered record, and every downstream consumer keep their exact shapes.

  • The interactive grammar reads script-wired controls, and the touch arbiter yields over every authored interactive surface (row 2437 — DJ's affected games; dumps 3238173e, 07242235, 18bbbfa7). The defect class: a pure-DOM control wired by script (el.onclick = fn, el.onpointerdown = fn) never matches the attribute-based interactive-opt grammar (INTERACTIVE_SELECTORS), so it never gets the pointer-events grant — its presses fall through to <body>, forward with overUi:false, and the host touch arbiter's geometric claim bands take the touch (stick spawn, look drag, world press) out from under the visible control; it presents as z-order eating taps, and inputs.touch.enabled:false "fixes" it only by killing the arbiter's claims. Five legs: (1) the grammar widens — isAuthoredInteractiveElement reads handler PROPERTIES (onclick/ondblclick/onpointerdown/onpointerup/onmousedown/onmouseup/ontouchstart/ontouchend), inline cursor:pointer, and inline self-opts (pointer-events:auto) beside the attribute selectors, and INTERACTIVE_SELECTORS gains the missing [ondblclick]/[onpointerup]/[ontouchend] attribute forms; (2) the DOWN verdict widens geometrically — a fall-through press whose point sits on a visible authored interactive control (findAuthoredControlAt) forwards overUi:true, so the arbiter yields (onCanvas: !pass.overUi) while bare-canvas presses keep overUi:false (movement sticks still win empty space); (3) the tap machine adopts fall-through gestures — the activation fires on the control (synthetic click at finger-up, iOS pointercancel verdict included) and the element heals (makeElementInteractive) so later gestures ride the native path, plus a 500ms grant cadence over the frame body heals controls wired after the morph walk (property assignment fires no mutation event); (4) the tap-rescue lane widens from the sendAction corpus to every authored interactive element — pure-DOM onclick toggles get the same guaranteed in-budget delivery; (5) lifecycle game mounts carry the data-input-capture="ignore" UI-plane marker the render container always had, so a mounted element that opted itself in reads overUi:true instead of being double-delivered (control AND engine stick). makeElementInteractive now preserves authored inline touch-action (a hand-rolled stick's touch-action:none is intent).

  • The relay ingress-age self-heal no longer misfires on the parked-drain trickle (dig 81ce11e4, row 2434) — parked time is not staleness, and the predicate gets a depth floor. Fix 2364's heal fired on age alone at the drain edge, but the ingress drain runs on the sim frame loop, which PARKS while the tab is hidden (shouldSelfScheduleWorkerRuntimeFrames) — so every ≥30s tab-hide over a dev room's background STATE trickle (~0.3–0.7 rows/s; the misfire ring: 40 heals in one creator's evening, 16–438 rows across 31s–1300s hides) read as a wedged drain and bought the lane's most expensive remedy: close 4439, the "Connection lost" overlay at the exact tab-return instant, and a resume=0 keyed-heads refold of a multi-MB spec to avoid draining a few hundred rows. Two guards, one per false class: (1) the visibility-resume edge re-stamps every queued relay.peer.frame row's arrivedAtMs (rebaseRelayIngressArrivalsOnResume — the staleness clock measures a RUNNING drain that failed to consume, so it restarts when consumption becomes possible; a drain genuinely wedged AFTER resume re-ages past the bound and still heals), and (2) the heal now requires material depth beside age — RELAY_INGRESS_STALE_REPLAY_MIN_ROWS = 2048, one relay journal self-compaction window (JOURNAL_SELF_COMPACT_ROWS), because a replay deeper than that is history the relay itself would have folded away for a fresh joiner while anything shallower drains in one gulp for less than the refold costs. Depth is a floor, never a trigger: a deep-but-young queue of distinct keys is honest load and drains normally (tide's fix-2364 law, kept). The true-fire class is untouched — fix 2364's motivating specimen (11.7k rows aged past 30s) heals exactly as before.

  • The self-addressed rail never forwards, and the relay-wire stamp is tri-state honest (pin 04da3c48, row 2430 — enfeul's every-join breakage). On a relay wire whose welcome-edge stamp (ClientRelayWireModeResource) read stale-FALSE, enterPlace took the kernel forward branch for the client's OWN avatar — but the relay excludes a sender from its own fan-out and the traveler's only simulator IS the sender, so the forwarded rail.enterPlace had zero appliers anywhere: id returned, membership never moved, structurally undeliverable. Three legs: (1) STRUCTURAL — the enterPlace relay fork now reads call-time wire truth (clientRelayWireTruth: the transport's live wireMode(), stamp fallback for reader-less worlds and the sniff hold) and widens self-apply to the receiving applier gate's own predicate (isClientAuthLocallySimulatedEntity ∪ the existing forward-gate admissions ∪ god-parked), so a traveler this seat would apply for is never forwarded; (2) THE STAMP — applyWelcome no longer writes wireMode()==="relay" as FALSE off an "undetermined" hold (first-frame sniff, between-sockets churn): an undetermined welcome PARKS the whole tier edge (stamp + uploader baseline adopt + world-sync arm) and the ingress drain completes it the tick the sniff resolves; a genuine relay↔kernel flip logs one rate-bounded line; (3) VISIBILITY — dump capsules now carry a wire stamp (live transport wireMode + welcomeStamp) via the netcode.wire worker debug door, so this class never needs a 13.5MB capsule dig again. E2E twin: relay join with the welcome applied inside the undetermined hold (the fresh-redial + backoff churn shape) → own-avatar enterPlace(createIfMissing) → membership moves and the minted instance expands on every fold.

  • playSound gains follow: true (row 2037, dig 55122dec). A positioned one-shot that names it mirrors its SOURCE entity's WorldFeetPosition while the voice lives — the primitive-not-pipeline fix for self-emitted movement sounds (installation-03's footsteps: spatial plays pinned at boot-plant positions trail opposite the mover's velocity for the life of every clip; bystanders hear correct physics, the mover hears their own boots behind them). Mechanism is the one loops already use: audio/prep re-reads emitter positions per frame, so the fix is a position mirror on the pooled carrier — spawnPlaySound records follow + the source entity in the one-shot registry (OneShotSoundEntitiesResource), and a new client system (tome/one-shot-sound-follow, renderPrep −110, ahead of audio/prep at −100) copies source→carrier each frame, making a follow voice exactly as fresh as an audio: loop on the mover itself. A source that despawns mid-clip stops mirroring and the voice finishes at its last position (one-shots outlive their emitter, by design). Version boundary pinned red-first: flag absent = byte-identical wire and behavior (unset one-shots stay world-pinned); the option rides the wire only on positioned non-loop plays that named true (unpositioned follow teaches once and lowers — non-spatial plays are already ear-locked; loops lower silently — tracking loops are the audio: component's lane). Pins: tome/__tests__/playsound-follow.test.ts (wire shape + client mirror, both halves).

  • Audio listener basis prefers the renderer's displayed camera (row 2036, dig 55122dec). readListenerTransform/readCameraTransform (engine/audio/prep.ts) now build the listener pose from viewState.rendererTransform (pos + rot) when renderer feedback exists, falling back to the script-authored viewState, then the entity transform — the exact preference the aim basis has used since camera-derived-axes' resolveViewState. Closes the latent audible-camera ≠ visible-camera seam: undeclared mouse-orbit rigs discard authored rot for display, so a camera script with missing or convention-flipped rotation writes produced wrong/mirrored audio with flawless visuals and correct aim, silently. Explicit AudioListener.forward overrides (the 2D fixed-basis law) still beat every camera-derived basis. Pinned red-first in engine/audio/__tests__/prep.test.ts (renderer-preferred, authored fallback, explicit-forward precedence, DrawCamera fallback path).

  • Savi surfaces: follow in the playSound/playSoundAt option types + @tomeapi doc (api-reference regen), and the audio skill's world-sound section names the trap and both exits (follow for self-feedback, or non-spatial).

  • AudioParam writes now go through a dedupe + suspend gate at the renderer seam (Safari P0, #w-rendering 2026-08-01). Every AudioParam write — including plain .value = — inserts a WebKit timeline event (AudioParamTimeline::insertEvent, one heap allocation each); prep re-sends full state every frame (≥16 inserts/frame with zero voices, ~6 more per voice), and while the destination is autoplay-suspended nothing consumes the timeline — measured ~12 MB/s WebContent growth until the machine swaps to death. Now a write whose target equals the last value actually written is skipped (per-param last-written cache; epsilon for ramp targets), and while ctx.state !== "running" no per-frame param write lands at all — desired state parks and the statechange back to running flushes one converging refresh. Ramps still schedule normally while running (no zipper-crackle regression); bounded one-shot writes (node creation, cull fades, duck ramps, vibe edits) stay ungated. Pinned by renderer-param-timeline.test.ts (red on the old renderer, 9/9 green now).

  • The erase brush clears EVERY painted material at once (jacob's punch list, #inventors 1785628548). Erase on a material brush was a sign flip on the armed field only — erasing "dirt" left sand and grass paint standing, so returning ground to its natural look took one erase pass per palette material. An erase dab (Alt, the Mode chip, or negative-strength armBrush) now runs the paint script over every authored terrain:material:* field in the place — enumerated from the fields that actually hold paint, so orphaned fields whose material left the palette clear too. Material fields ONLY: marks, terrain:height, decor:*, and the generator's natural weights are untouched — erase returns the texel to its natural generated look. The expansion is per-dab and never persisted (brush.fields stays the armed field); commit/undo bookkeeping now iterates the stroke's touched-field ledger, so one undo restores every field the erase cleared.

  • Replace mode: paint that wins. New third Mode on material brushes (chip: Paint / Replace / Erase; session flag replace; armBrush({ replace: true })). A replace dab paints the armed field exactly like additive paint (max(current, amount), amount = falloff × strength) and additionally scales every sibling painted material field by (1 − min(1, amount)) — engine math in the session, so the paint-script contract stays one-field and overridden material scripts inherit the mode. All siblings scale by the same factor (their relative mix is preserved as they fade); at strength 1 a single core dab zeroes them. Natural generator weights still compose underneath (chunk-build's additive law is unchanged), so strength remains the lever against natural coverage: replace-at-2 visibly dominates. Default stays additive — jacob ruled additive correct; replace is the opt-in.

  • Cursor label gains "Replacing"; the Mode chip label gains the same. Non-material brushes keep the two-option chip (no sibling family to lower).

  • Pinned in brush-material-erase-replace.test.ts: erase clears all painted material fields (armed-or-not) and leaves height/decor alone, emptied fields drop from the spec, one undo restores them all; replace scales siblings proportionally (1.0/0.5 → 0.5/0.25 at strength 0.5) and compounds across dabs; Alt-erase beats an armed replace; additive-without-the-option writes no sibling chunks at all; the chip index contract; the armBrush surface.

  • Engine diagnostics adopt jacob's DM taxonomy (#inventors 1785635891): everything in logs; errors and storms ping. Every client-reported diagnostic still lands in getLogs() the moment it arrives (unchanged). Each of the 98 rail codes in engine-diagnostics.ts now carries a class: error (broken/wedged/terminal — script compile/runtime faults, renderer crashes, exhausted self-heals, physics corpses, engine parks of authored content) DMs Savi once per episode on first occurrence, with repeats counted to the log; warning (renders degraded-but-playable, placeholder shows, self-heals, teaching, environmental — texture/model load failures, missing clips, slot exhaustions, quota parks, schema skew) never DMs per instance. Either class escalates ONE storm DM when a single code accumulates ≥25 reports inside a 5-minute window ("N reports of this kind… something systemic; getLogs has every instance"). The old trailing "+N more" rollup flush — one hidden Savi model turn per active code per window — is deleted, along with its timer machinery and budget-carry branches; the perf-pointer lane (#235 hourly categories) and the room-global 5-DM/min budget are untouched. Class table pinned in engine-diagnostics.test.ts.

  • Query iteration SoA component VALUES are flyweights (ruled 2026-08-04, squeeze hunt ECS core). The engine-internal QueryResult iterator contract narrows: soa3f32/soa4f32 columns (WorldFeetPosition, BodyPosition, WorldScale, WorldRotation, LocalFeetPosition, LocalRotation, LocalScale) now hand out ONE reused value object per column per QueryResult, refilled per row inside fillRow — the last per-row mint #12093 left standing (one {x,y,z} per SoA row per pass; a 20k-row soa3 query paid 20k mints per iteration, now 1 per column per QueryResult). toArray() keeps fresh mints per row and stays the retain-safe form. Audit receipts (bound: every .query( call site across apps/cf-kernel/src including tests and benches; positive control: the known SoA-tuple site tome/api/query-utils.ts:554 and the flyweight row-identity pins were both hit): exactly one non-test SoA-tuple iteration exists (query-utils.ts emitFullScanMatches — consumes in-loop, scalar emit, no retention), all test SoA queries use toArray(), no import aliases of the seven SoA components, no multiline query calls, no generic wrapper passes SoA tokens into world.query. Zero retainers found, so no consumer migration was needed. The script-facing boundary stays fresh-mint by construction and is now named in the QueryResult doc: api.query builds its own results (query-utils.ts buildQueryResult captures scalars and lazily mints an escaping Vec3), the exec overlay's query builds its own rows from staged values, and world.get mints per call — the flyweight never crosses the ECS layer. This change is invisible to behavior scripts and run_script.

  • Pin flip (documented per the ruling's duties): the retain-safety pin was documentary — the QueryResult interface doc ("SoA columns a fresh object per row", world.ts) plus a comment in determinism-pins.test.ts; no executable assertion pinned value freshness. Both texts now state the flyweight contract, and a NEW executable pin ("SoA column values are per-column flyweights during iteration; toArray mints fresh values", determinism-pins.test.ts) asserts one reused value object per column with correct per-row contents, and fresh distinct objects from toArray().

  • drainEventAdds returns a shared frozen empty array when nothing fired (the common case for its ~11 per-tick/per-frame drain sites — juice ×3, sound-loop ×3, purchase prompt, renderer particle burst, audio prep/deriver: ~400-800 throwaway arrays/s per session at rest, now 0), and its declared return type is readonly StickyEventAdd[] so tsc enforces the read-only caller contract mechanically (all 7 production call sites audited read-only; a caller pushing onto an empty drain now throws in strict mode instead of silently cross-contaminating drains). sound-loop-ingest processes its three juice drains in place instead of spread-concatenating them into a fourth array per server tick.

  • Replication drains refill a persistent changedComponentIds scratch in place instead of minting Array.from(set).sort(freshClosure) per drain — both the ECS-level drain (engine/ecs/replication.ts) and its production twin (runtime/replication/room-replication.ts, one drain per AOI-bucket class per egress tick). Consistent with the delta-is-reused-scratch contract already documented in replication-delta.ts; consumers (room-wire-codec frame encode) read synchronously within the same frame build.

  • behavior-update's per-tick debug readout (TomeBehaviorUpdateDebugResource) mutates one engine-owned resource object in place (ensure-once + six field assignments at all four system exit paths) instead of minting a fresh object literal per tick per world. Reader census: the sim-probe markers handler (structured-cloned at post time), tests, and the behavior bench all re-read the resource per observation; none diff object identity across ticks; behavior-update never runs inside an exec overlay.

  • New bench coverage for the two blind spots the sweep named: ecs/query gains query/1-soa3 (an soa3f32 population — the storage class every transform/pose query actually iterates; the old fresh-mint path measured 283µs/pass vs 254µs flyweight at 20k rows, mint kill 20k→1 per pass) and a churn/commit+churn/query pair (one spawn+despawn per iteration invalidates every cached query shape; measured ~2ms/churny-iteration of snapshot-rebuild tax at 20k entities across 3 shapes — the receipt for the still-open query-snapshot-rebuild-under-churn row, which this PR deliberately does NOT implement: incrementally-maintained snapshots need lend-bit copy-on-write across every store×shape pair plus a mass-churn fallback to hold snapshot identity byte-identical, a design project with engine-wide blast radius, skipped honestly per its own P verdict).

  • The game document can no longer paint a scrollbar (tiger, #inventors 1785641498). The engine page's html/body had no overflow guard, so any in-flow element that outgrew 100svh by a few px — in tiger's session, a transient during an art cook, frame-coincident with the kiln "Spawning art" chip — painted a full-height document scrollbar over the game's right edge on always-visible-scrollbar setups, and window.scrollTo could slide the whole canvas (measured: 8px in-flow overflow → scrollHeight 892 in an 884px viewport, scrollTop movable). Root overflow: clip now propagates to the viewport as hidden (CSS Overflow §3.3): no document scrollbar, ever, regardless of what transient DOM appears. In-game scroll surfaces are untouched — realm UI already scrolls inside its own overflow: hidden chrome-host container, and the kiln page's own scrollers (chat, settings) live in the parent document. Pinned by game-document-overflow.test.ts.

  • The stalled-spec-lane HUD freeze dies at the sample boundary (row 2186 — pinball 65cc2257/4af4094d, Dayspring aa6bb90d). The game HUD's compile slot now keys on CONTENT, not mount time: CompiledSpec.uiSource records the exact source ui.render compiled from, and the HUD receipts' compile breadcrumb carries sourceHash (the live doc's resolved render-source hash), so compile.sinceMs re-stamps when the authored content moves — "pinned at boot" can no longer read identically for a healthy lane and a wedged one. When the live doc's ui source diverges from the compiled fn's content key for a sustained window (~3s at 30Hz, the same bar as the compile-missing confession — far past any legitimate apply→compile gap), the controller recompiles the render from the live doc through the same content-keyed cache a real apply uses (a later apply of that content is a cache hit on the healed fn), swaps it into the serving graph in place, and confesses once on the fault rail naming the stalled apply lane. A source that fails to compile keeps the old render serving, reports through the ordinary compile reporter, and re-arms on the next edit. Field signature this ends: compile ok/sinceMs pinned at boot across ui.js v696→v708 while sibling edits applied, sent frozen, deduped past 88k, restart-rooms as the only cure.

  • The input-binding validator stops billing Savi for entries the platform wrote (r-20a666a8, ravi's 08-01 playtest dig 3c3d2509). Every 3D game minted from the starters carries lookX/lookY/aimYawSin/aimYawCos/aimPitchN in inputs.axes — the starter template ships them — and the #11518 validator flagged all five as "engine-internal axis … Remove this entry" on the merged map, so EVERY patchInputs (including ones that never touched axes, including writes that later rolled back) drew 5 warning lines for entries Savi never wrote. During the 08-01 playtest the noise drowned the real schema errors in a 19-second terrain thrash. The condition was both provenance-blind and factually wrong for two of the five names.

  • lookX/lookY leave the scold entirely — they were never "engine-internal" in the claimed sense. They are binding-fed axes with an engine DEFAULT (buildDefaultBindings injects the mouse binding only when undeclared — the hasOwnProperty guard exists precisely so declarations override, and gamepad-look treats explicit rightStick → lookX/lookY bindings as the look gesture). Declarations now validate like any axis: a remap is silent, a junk source names itself, and an EMPTY lookX: {} — the one genuinely hazardous shape, since it suppresses the default and binds nothing — gets an honest teach ("replaces the engine's default mouse-look binding with nothing … remove the entry to restore the default") instead of a false one.

  • Engine-computed axes (aim*, cam*, pointer*, touchActive) warn only on the genuinely-wrong shape: a declared hardware source. input-resolution seeds each axis from the engine's setAxis write, then keyboard bindings override it and mouse/gamepad deltas add to it — so aimYawSin: { keys: "q/e" } really does fight the camera-derived value, and still warns (with the true mechanism named). A sourceless {} declaration compiles to nothing, blocks nothing, and is exactly what the platform's own templates planted — it is now silent everywhere (patchInputs rail and spec-sync console lane both ride collectInputBindingFaults).

  • Multiplicity: one line per patch, never one per axis. All sourced engine-computed axes in a map collapse into a single fault line naming them together — the 5× per-write fanout shape is structurally gone.

  • The planting stops (structural elimination). The five declarations are deleted from packages/game-starters/starter-3d.ts, cf-kernel's DEFAULT_GAME_SPEC, and kiln's DEFAULT_ADVENTURE_GAME_SPEC (behavior-identical: the engine injects the same mouse-look binding when undeclared, and the aim basis never read declarations at all). The teaching corpus stops teaching the shape: the camera skills' "Required Inputs" JSON and the @tomeapi-example camera SPEC-REQUIREMENTS lines now say these axes are engine-provided reads, declared only to remap look. The starter drift fixture is regenerated in this commit.

  • BREAKING: query results and object summaries hand out the live tracked state proxy (ruled 2026-08-04, supersedes #12092's detached snapshot). buildQueryResult and getObjectSummary (tome/api/query-utils.ts) return .state from the shared per-(world, bag) proxy cache in the new tome/api/tracked-state.ts — the exact object getObject()/getState() return. Reads are 0-alloc off the stored bag (the per-read {...state} spread + read-tracking wrap chain is deleted); writes mark the component updated, replicate, and ride the client-auth intent rail; a retained result observes live state. getMergedTomeState/getMergedTomeStateByIndex (state-utils.ts) and trackClientAuthSnapshotState (intent-context.ts, the detached cross-writer snapshot wrapper + its per-call WeakMap/JSON.stringify path cache) are deleted — the tracked proxy's own traps record reads/keys/has-probes into the ACTIVE invocation at trap time (same dynamic attribution, plus the toJSON-probe basis exclusion the snapshot tracker had).

  • BREAKING at one seam: native structuredClone of a result's .state throws (proxies are not structured-cloneable). Scripts are unaffected — their structuredClone is the unwrapping bridge (script-value-clone.ts; every tracked proxy mint now registers in its proxy registry). The exec result boundary (exec/engine.ts finishOutcome) unwraps registered proxies before the reply crosses, so return api.query(...)/return api.getObject(id) from run_script now arrive as plain data (getObject views previously DEGRADED into a serialization note — fixed by the same unwrap). api.job args gain the same membrane unwrap as the property-write lanes.

  • Tracked-proxy write path (row: tracked-proxy-write-double-materialization): the set trap stores to the TARGET directly and fires the recorder itself, instead of receiver-routed Reflect.set re-entering the defineProperty trap — one quantize walk, one markComponentUpdated, zero descriptor mints per write (was 2 traps + 2 quantizes + 2 marks; object-valued writes paid the whole container copy twice). Recorder emission stays the single funnel, after the local apply. Bench (this box, micro): getState mutate top-level 321→170ns, nested 319→163ns; object-valued assign 277ns single-copy.

  • patchState/patchObjectState/deleteState (row: patchstate-triple-whole-bag-walk): an O(patch) write verdict (statePatchChangesStoredState, the deepMergeState mirror under the column write's own fastDeepEquals law) runs BEFORE the merge — a changes-nothing patch skips the {...current} merge copy, the full-tree sanitize walk, and the full-width equals walk (cross-writer intent emission and the ledger-1157 durable-mirror suppression contract are preserved verbatim; the specValuesEqual persist verdict stays its own law). Changed patches take the new trusted pre-sanitized lane — world.setResolvedByIndexPresanitized / setObjectMapPatch(..., presanitizedChanged) — which skips sanitize (merged bag is wire-safe by induction: sanitized stored bag + membrane-quantized patch values) and the equals gate (verdict already proved the write lands). Raw engine world.set callers keep the full sanitize net. Bench: patchState 2-key vs 50-key bag 1053ns, no-op 193ns, 2-key vs 100-key nested bag 2267ns (new cases).

  • getObjectSummary (row: get-object-summary-eager-materialization) rebuilt on the lazy-accessor literal: id eager; tags/feetPosition/state memoizing accessors — contact/interact/voxel payloads stop paying the whole-state spread + tags copy + position mint when the hook reads only other.id, and land on the SAME final shape as query results. The missing-position {0,0,0} default still skips the physics-quantize check (hadPos capture); present positions keep the read-time pose-driven check.

  • Script-transaction overlay (row: overlay-entity-walk-per-query): currentEntityIds is memoized against the overlay's own membership funnel (spawnEntity/despawn — the base world is frozen for the overlay's lifetime), the O(spawned×N) includes scan is gone, and query-row iteration memoizes on a staged-write epoch (bumped by every setComponentPatch and membership mutation; rows embed references, so in-place read-clone mutation needs no edge). Bench (new case, no pre-change twin): 50 staged spawns + 50 entity enumerations over 3k entities = 272µs total (~5µs per enumeration); the deleted term was O(N + spawned×N + N log N) per enumeration — ≥ 150k includes compares + a 3k sort each, at this shape.

  • Flipped pins, each one the ruling's own semantics: query-lazy-results.test.ts "state stays a detached snapshot: result writes never reach the world" → "state is LIVE: result writes land"; its native-structuredClone parity pin → script-bridge clone + native-throw pin; new pins for door identity (query result / summary / tracked door alias one proxy) and live retention. client-auth-intents.test.ts rewired from the deleted getMergedTomeState door to getObjectSummary(...).state (all basis-recording expectations unchanged — get/has/ownKeys/JSON recording now comes from the tracked proxy's traps); its native structuredClone-of-snapshot assertions flipped to the script bridge. query-fast-path.test.ts reference impl reads the tracked door. New exec pin: returned query results/views cross the wire as plain data (clone-fallback.test.ts).

  • Behavior-preserving invariants pinned by the untouched suites: client-auth e2e + intents (basis order, inc/set classification, revert inverses), script-transaction overlay + tx-spatial query parity, exec snapshot withholding drift pin, determinism pins. Caches name their invalidation edges at the definition site (tracked-state.ts header; overlay memo comments).

  • An in-place player always outranks the standing (headless builder) session in the per-place host election (ledger 2464, incident f466014b — Blob Wars). A room_host-class session never ranks at resident tier: its body residence folds as one more body-independent claim (relay-place-elect claimAnchor), so any eligible real client standing in a place takes that place's seat — in the v5 fold, the room-truth derived election, and the fold's honoring gate for relay h:* assignments (a relay predating the rule can still journal a shell seat; the fold degrades to the derived election and seats the resident player). cf-relay's own seat brain carries the same rule: host-session:* candidates rank behind every real client in pickWinner, and a SITTING shell yields at the first qualifying real-client beat — the one carve-out from first-eligible-never-migrate besides graded health. The shell still hosts every place that is empty of players — that is its job, and the fail-closed floor (weak/cooling/shell beats hostless) is untouched. Before this, the shell — present whenever Savi runs world probes — could win a place a live player stood in and host it with no physics runtime and no terrain definition: raycasts answered null, placements parked, bots embedded in the floor, while the player's own sim-health read green.

  • restart-rooms verdict honesty on client-hosted rooms (the same incident's rider). A relay world reset that WENT OUT with only its receipt missing now answers the structured verdict reset_sent_unconfirmed — never degraded — and the message says what was done: the reset was sent to the client-hosted room, there are no server rooms to restart by design (architecture, not failure). The field reset provably landed (journal + heads cleared, host re-elected) while the old verdict read 'degraded / no server-hosted rooms', teaching Savi her one remedy fails.

  • The physics-anchor chunk-residency guarantee lives with the collider-assembling pass (ledger 2373 re-land of #13063). LOD0 chunks now mint under every dynamic body the local realm simulates — awake vehicles unconditionally with velocity lookahead — in every world class that assembles colliders: singleplayer, multiplayer client-auth clients (the class that was dead), zero-player rooms (spawn-fallback streaming), and seat-holding container-tier servers. The guarantee was keyed on isSoleClientStreamerWorld (client && singleplayer), so no pass ever minted the chunk under a far body in a client-auth room and the chunk-rescue fought each ballistic re-fall forever (dig 525e1a23: 1,942 fires in one session). The stationary-player coords fast path and the server idle-skip signature fold in the anchor CELLS, so a vehicle crossing a chunk boundary under a stationary player busts the cache while an anchor moving within its chunk keeps it. Anchors are owner-scoped in multiplayer (the physics step's own follower boundary), so N clients never pay N× chunk builds under each other's followers.

  • What the revert demanded, cured structurally: #13063 was reverted (2ef614ede4) because its static streaming.ts → @/tome/client-auth import closed a cycle through client/netcode (streaming.ts is inside netcode's own graph via netcode-transport → relay-peer-apply → edit-wire → tilemap), breaking vi.mock module identity — KernelTests shard-2/3 red on master (client-auth-intent-deferral, relay-terrain-edit-lane; the ledger-752 class recorded on netcode.ts's relay-tome-consumers seam). The re-land injects the server ownership predicate through a leaf seam (physics-anchor-scope.ts) wired at module scope by terrain-systems-shared.ts, which already legally imports client-auth; streaming.ts gains no tome/client-auth edge. Both formerly-red suites are green with the fix applied (51/51).

  • Held seats gate the server pass, never the declaration (codex P1 on #13063): includePhysicsAnchors and the idle-skip signature's anchor gather key on hasAnyServerHostedPlace, not isServerPlaceHostWorld — a declared place-host world whose seats are all client-held has a dormant sim stack and pays no per-tick body traversal for anchors it never owes.

  • chunk_rescue_remediation_failed with chunkEntity=missing is a RESIDENCY verdict ("streaming never reached here") with a structured verdict field, instead of blaming rebuild requests that were never submitted.

  • Placeholder concept images keep their aspect (tiger's squashed shinobi, dump ad7dcd12). The mcdn placeholder ladder's image rung stretched the decoded preview across the full square hologram quad (packPlaceholders packs one scalar drawn size; UV spanned the whole quad), so a 1024×1536 portrait humanoid card rendered 1.5× too wide. packPlaceholders now packs a contain-fit rect (placeholderPreviewFit — the fitted rect as a per-axis fraction of the quad, computed from the decoded texture's own dims) into the instance's two free pad floats, and the preview shader letterboxes the image to that rect — centered horizontally, bottom edge at the entity's feet, fragments in the letterbox margins discarded exactly like the BiRefNet-cut alpha. Quad size, pick volumes, crossfade, and TAA velocity behavior are unchanged; procedural/failed instances keep byte-identical instance data.

  • World touches in the authored realm survive the browser's pan-slop — the mobile touch stick stops vanishing on slight movements (incident 300a3b65, Savi-filed; enigmatic1's report, 08-01). When a game has live authored UI, the realm frame iframe is the viewport-wide hit target for every touch, and a fall-through press (target <body>/<html> — the pointer-events law) is the world's: the platform stick, look-drag, and world gestures. The frame document shipped with no touch-action posture, so the browser's touch gesture disambiguator was free to claim those presses for a pan at its ~10px slop — pointerdown → pointercancel, no pointerup — and Chrome cancels even when nothing in the chain can actually scroll (browser-gate receipt: non-scrollable host, non-scrollable frame, micro-drag still cancelled). The arbiter then correctly ends the analog claim, so the stick died ~10px into every drag — forever under its own 8.16px deadzone, which is exactly Savi's filed observable (touchActive:1 with moveX:0/moveZ:0 while the player holds the stick, "the stick widget vanishes when pressed or nudged slightly"). The shell now pins touch-action: none + overscroll-behavior: none on the frame's html/body: a world press can never start a browser pan, so its pointer stream stays the platform's from down to up. Same law, third layer — the canvas has carried it since day one (browser-host/dom.ts), kiln's game routes carry it (game.css), and the frame the UI actually lands touches on now does too.

  • Pinned by tests/browser/suites/world-touch-stick.pw.ts (real shell + real frame bundle, CDP touch): the micro-drag world press delivers down → moves → up with zero pointercancel under a scrollable ancestor chain AND when the frame's own root scroller has extent (body touches pan neither); mobile-authored-ui-actions.pw.ts's scroll-stays-scroll matrix pins that authored scrollers still really scroll (CDP flick moves #shop-list). Arbiter-level law pinned in arbiter.test.ts: an analog claim has no slop-cancel — sub-slop micro-moves plus a hold past every gesture window keep the claim alive to the lift.

  • api.replaceAcrossScripts(search, replacement, opts?) — the bulk-rename verb (r-a6e3cbe4, derrf's town rename). Multi-file find-and-replace across every game script: literal text by default; opts { regex?, caseSensitive? (default true), includeScripts?/excludeScripts? (glob or exact-path list), dryRun? }. dryRun: true returns the full report — {script, line, before, after} rows plus per-file match counts — with ZERO writes; the wet run returns the same report with applied: true. Each changed script stages through the existing setScript lane (one spec swap + one recorded setScript mutation per file), so persistence, forwarding, and undo ride machinery that already exists; the report carries a per-file verdict instead of promising all-or-nothing. Zero matches is an empty report, never an error. memory/ and skills/ keys are never candidates, so a sweep cannot even attempt the writes those lanes reject.

  • Replace mode fully replaces — natural ground included (jacob's 50/50 crop, #inventors 1785628548). Replace previously zeroed painted siblings only; the generator's natural weights still composed underneath, so replace-at-1 over natural grass landed at 50/50 — "it's NOT winning it's merging". Natural coverage is derived (materialAt re-evaluates per texel), so there was nothing stored to write down; the suppression now persists in its own channel, terrain:natural-mask (0–1). A replace dab raises it to amount (max-composed, clamped to 1) alongside the sibling scale; chunk-build scales the generator's material result by (1 − mask) BEFORE marks and paints compose — a full-strength core is pure painted material. An erase dab writes the mask back down (subtract, like the material script's signed erase), so one erase gesture returns replaced ground to its natural look and the emptied mask drops from the spec. The mask rides the stroke's touched-field ledger: one undo restores paint, siblings, and suppression together. Marks stay outside the mask — they're authored decoration, the same boundary erase draws.

  • The mask is an ordinary field: readField/writeField see it (documented in the ObjectAPI field docs), the scatter material matcher applies it (ground the renderer shows as pure painted material reads as that material for material-conditioned beds), and the terrain inputs hash carries it name-keyed beside the painted material fields.

  • Texture transitions sharpened (the blendWidth follow-up #11801 deferred). The pbr weight resolve's crossfade window was floored at TERRAIN_HEIGHT_BLEND_DEPTH (0.18) — as wide as the ±0.18 texture-height bias that decides which layer wins each texel, so across a whole transition band the losing layer rendered THROUGH the winner: 2:1 and 50/50 paint ratios both read as a translucent ghost-merge. The window floor is now its own constant, TERRAIN_BLEND_SHARP_WIDTH = 0.06 — the height bias still carves which texels each material wins (grass blades between stones), but the loser fades out within a texel-scale band: dominant paint reads as dominant-with-speckle, partial rims dither instead of washing. Non-pbr paths (dither, tier ≥ 1 winner-take-all) are untouched. The verify-terrain-blend device gate is re-pinned to the sharpened character.

  • Pinned in brush-material-erase-replace.test.ts (mask raised proportionally, erased back down, spec-dropped when emptied, restored by one undo, untouched by additive paint) and terrain-jobs.test.ts (a full-mask texel composes as painted material alone; the mask changes the inputs hash).

  • Perf: flyweight set-event dispatch for motion-class SoA components (speed-of-light row soa-dispatch-flyweight, ruled 2026-08-04). A new SoA-only schema opt-in transientSetEvents routes setSoA3/setSoA4 subscriber dispatch through one pooled event object (+ pooled previous/value vecs) per store instead of minting three fresh objects per dispatching write — the top steady-state GC feeder at 3k-moving-entity scale (~270–500k objects/s → ~0).

  • BREAKING (engine-internal subscriber contract only): for the seven opted-in components — transform/world-feet-position, transform/world-rotation, transform/world-scale, physics/body-position, tome/local-feet-position, tome/local-rotation, tome/local-scale — an onComponentSet event object retained past the callback is overwritten in place by the component's next dispatch. Every in-tree subscriber of these components was audited (spatial-index, hierarchy-solve, hierarchy-render-solve, local-transform-projection, rapier/sync, aoi-index, sprite-raycast, perception, nav-grid, overlap-probe-cache) and reads fields synchronously; onComponentSet is not reachable from creator scripts or any run_script/ObjectAPI surface, so no session-facing behavior changes and no banked-claim falsification exists for this change. Re-entrant same-component writes fall back to fresh mints; dev-mode mutation tracing forces fresh mints throughout. Contract: src/engine/ecs/contract.md (Subscriptions), pins: src/engine/ecs/__tests__/soa-dispatch-flyweight.test.ts. No pre-existing test pinned the old fresh-object identity for these components, so no pins were flipped — the new pins are additions. Savi-latency cluster (squeeze-hunt round 2 — the apply path's second act after the changed-set-first visit filter): a non-short-circuited applySpec re-derived spline/room lowering for the WHOLE world on BOTH diff sides, re-ran ~6 whole-spec derive/library sync walks ungated, rebuilt every per-apply container, and every attributed ObjectAPI/fold spec write copied the ENTIRE object-diff-signature map to evict a handful of keys.

  • Per-row lowering memo (src/tome/lowering-memo.ts): generative-spline (road/powerline/stairs/coaster/preset/fence/hedge/pipe) and room rows reuse their lowering products by reference when the source row (by identity), place mode, installed terrain definition token, and the composed terrain:height chunk-version fold over the entry's padded bounds are all unchanged. The field token is the SAME fold the spline redrape watcher re-lofts on (shared helper), so "the ground moved" can never mean two things. Scripted-kind splines, generator-script-bearing rows, scatter rows, and lookAt rotations are excluded and re-lower fresh every apply, exactly as before. Eviction edges: identity replacement (ObjectAPI/COW-fold writers), the spline drag preview's sanctioned in-place row write (explicit evict beside the diff-signature one), terrain def/field tokens, per-apply pruning to the final new-side expansion, whole-memo drop on a failed apply.

  • Old-side expansion reuse (TomeAppliedGeneration.expansion): under the delta-0 gate's own completed-apply binding (specRef === live doc) plus expansion-time keys (residency, place-instance content, player-body derivation inputs — a new computePlayerBodyDerivationKey covering active players and session-parent liveness — and the unfiltered-record identity), the next apply's old diff side reuses the previous apply's FINAL new-side expansion whole, containers included (flat list, byId, placeById, id set). Omitted whenever a survived-destroy retry row diverged the recorded doc from what was expanded. Old-side lowering failures stop double-reporting (the recording apply's new side already reported them once).

  • Caller-mutation guard: everything recorded past an apply (memo source rows, expansion defs) is re-bound to the ENGINE-owned clonedSpec row twins — "row identity ⇒ content unchanged" holds for immutable-by-replacement engine docs, not for a caller's own doc mutated in place and re-applied (the pinned in-place scatter-count editor pattern stays a full visit).

  • Diff-signature eviction side index (object-diff-signature-invalidation.ts + TomeObjectDiffSignatureIndexResource): a root-id → key bucket index built once per apply beside the signatures map makes attributed spec-write eviction O(touched roots) with in-place deletes, replacing the per-write whole-map copy + full key scan. Unbound index (any non-apply map identity) falls back to the historical O(N) scan-into-fresh-map path.

  • Derive-pass skip gate (TomeDeriveSyncInputsResource): the six library syncs (materials, field-feed demand, authored clips, looks, textures, warm hints), the spec-shape warn rail, and the asset-manifest merge are skipped when every raw subtree they read (scripts/places/assets/camera/player identities) plus the row-derivation keys match the last completed apply — with per-pass derived-component presence checks so projection resets (which recreate the tome/spec entity bare) always re-derive. Runtime ensure-hooks, the renderer join-watchdog force lane, and TomeSpec reset hooks call the sync functions directly and never see the gate.

  • Container floor: buildObjectDiffSignatures returns the previous map by identity on a 100% hit (consumers pair every signature lookup with an oldObjectById lookup, so a superset map is safe); the reused old side ships its containers from the generation record.

Bench (spec-fold-cost.test.ts, new apply-path case: 1,060 source objects — 20 roads + 10 powerlines + 10 fences + 20 rooms + 1,000 flat — through the fold lane): delta-1 apply median 51.5ms → 12.4ms (expand-old 15.3→0.4ms, expand-new 14.8→0.4ms, signatures-new 15.2→2.6ms); 200-write burst 439µs → 112µs per write. Anchor (ecs/soa read-ts-plain) 13,107ns.

Named residues, deliberately not built here: the compiler's second child re-expansion per apply (compileSpec runs before expansion — reordering is its own change); pooling of genuinely apply-local scratch (small, and reentrancy-sensitive); the rebind-candidate query walk. Resident-memory note: the generation record now retains one expansion's containers and the memo retains lowering products between applies — bounded by the currently-expanded world, released on prune/replacement.

  • GLB texture payloads stop pinning whole files (jetsam cluster, round 2). parseGlb minted texture bytes as views over the GLB input buffer, so any retained parse pinned 100% of the file — geometry, JSON, and texture payload — for the retainer's lifetime. Three cuts along one seam: (1) GLB-embedded image bytes are now OWNED exact-size copies, so the fetch buffer collects after parse (renderer-side retained drops from whole-file to texture-payload-only; the budget estimator's texture term becomes honest by construction); (2) the sim asset service parses with retainTextureBytes: false — the sim worker never decodes or uploads textures (reader census: zero sim-side consumers), so the texture share (often 50–80% of a textured GLB) never becomes resident in the worker-lifetime modelsById cache at all; (3) the renderer models registry releases parsed.textures[].bytes once every decode for the asset settles — the GPU copy is the source of truth, device loss is a terminal reload wall (recovery re-fetches + re-parses), and assets are created once per modelId per scene, so no in-session reader remains. Receipt (8MB-texture synthetic GLB, reachable-backing-buffer arithmetic): old = whole file pinned per retained parse; owned-copy = texture payload only, input unpinned; sim flag = ~0; post-settle release = ~0.

  • Render-channel writer scratch sheds its burst high-water. The spill-encode scratch doubles toward MAX_ARENA_BYTES (512MiB) during boot/backlog bursts and was never re-minted smaller — the high-water stayed resident for the session (the transport-residency stack named in ecs-sync's collapse-gate comment; PR #12099 iPhone-16 jetsam kills). It now re-mints at its 256KB rest size after ~30s of active sim ticks with zero spill demand and an empty pending backlog (frames never alias scratch — pushScratchFrame copies). Re-materialization is the existing cold-path shape: the next spill's ensureScratch(16MiB). New getScratchCapacityBytes() observability getter.

  • Preload-hint derivation survives metadata-only spec landings. collectSpecPreloadHints memoized on whole-spec identity, so every asset-metadata landing (bounds batches, the per-analyzed-model triangle stamp, socket/parts rails — dozens per boot, plus every landing mid-session) paid ~5–7 regex passes over the entire script corpus plus place/object walks to re-derive an identical answer. The four metadata writers now land through one shared helper that forwards the memo when every derivation input identity is preserved (input set positively bounded: collectSpecAssetEntries reads places/terrain/atmosphere/objects/player/scripts and never spec.assets; sprite-warm-variants reads scripts/places/objects/player) — a hit returns the IDENTICAL array, so the fast path's previousRefs delta stays a no-op.

  • Sidecar host-derive rails stop walking the world. Both rails' ~1s scans iterated every TomeParent-bearing entity (the whole parented population) on every metadata-authority client — including all singleplayer sessions — even with zero socket/part attachments. A hook-fed per-world attachment index (TomeParent add/set + one-time seed; the BoundsPrefetchFeature needs-set shape) makes the scan O(attached-children), i.e. zero for the overwhelming majority of games; stale ids prune on visit. Riders: the per-tick completed.splice(0) empty-array mint is gated on length; the authority resolution (place-hosts read + session query walk) is skipped entirely on ticks with nothing to scan or dispatch; tombstoned entries now cost two boolean checks per tick instead of nested spec probes (resolved-entry cleanup — including a tombstone's cap seat — rides the scan-cadence sweep, with a dispatch-due re-check so a resolved pair is never fetched).

  • Perf: squeeze round-2 jetsam cluster (half A) — budgets and eviction for decoded media, behavior-preserving (sol-table .tmp/squeeze-hunt-2026-08-04/round2-rows.json). Decoded audio PCM gets the aggregate budget MAX_BUFFERED_CLIP_BYTES never was: the resource service keeps a raw-decoded-bytes ledger in LRU-touch order and, when a decode crosses the per-device budget (AUDIO_DECODED_PCM_BUDGET_BYTES in perf-static-data.ts — 128/64/48 MiB by the texture/model keep-alive facts, the table's third instance), releases oldest-touched buffers first, pinning the WebAudio renderer's live voices + pending starts (and the just-decoded clip, so one over-budget clip can never decode-loop). Only handle.buffer drops — handles, stream elements, duration memory, and failure/park budgets survive, so an evicted clip re-decodes on demand through the exact paths that loaded it; the re-decode latency on a cold retrigger is the traded term. getStats().decodedPcmBytes is the gauge. The video warm cache's flat 64 MiB budget becomes device-tiered through BOTH construction sites (the shared main-thread cache and the tome-UI frame warm lane): VIDEO_WARM_BUDGET_BYTES 64/32/16 MiB — a mobile miss is the module's own documented designed degradation (plain JIT streaming). Renderer-side: runModelEviction gains an exact O(1) early-out (running totalCachedModelBytes maintained at the cache's set/delete/clear seams; unreferenced ⊆ cached, so under-budget frames skip the per-frame walk + sort + array mint — getModelEvictionStats().cachedModelBytes pins drift). fx particle rasters stop being a session cache: they release by poll recency (every consumer — pack compositor, content-scale measurement — polls per frame until it settles, so 5s of silence IS the settled signal; "unavailable" tombstones stay as the refetch-storm guard), and a composited pack drops its captured per-layer CPU rasters instead of pinning them for the pack's lifetime. PipelineCache gains pipeline-key retention: material instances retain the variant keys they mint and release them on destroy, so an edited-away scripted material's driver-compiled PSOs leave the cache with the record (scripted-lane idle-LRU eviction, tilemap/decoration scripted retire, booth cleanup — all one seam) instead of living to device loss; drops bump generation so draw-memo pipeline-handle memos re-request (a map hit for survivors), a booth previewing a live material holds its own retain, and un-retained keys (fixed shaders, post chain, lanes) keep session lifetime exactly.

  • Named residuals: the audio ledger counts raw PCM only — a looping clip's conditioned copy rides the raw buffer's lifetime and roughly doubles its true cost (the budget is deliberately conservative against it). A raster whose pack died mid-fetch stays resident for one grace window before the sweep drops it.

  • Perf: speed-of-light squeeze wave 3, tick spine + timers (sol-table .tmp/squeeze-hunt-2026-08-04/2026-08-04-v2-whole-sim.md). The client runtime finishes the server's sharedCtx shape: per-system SystemContext objects, state closures, and push/pop run closures are cached at rebuildPhaseLists (tick/dt/jobs re-stamped per run), ctx.rng becomes a per-run lazy getter deriving the identical (epoch, tick, phase, name) fnv seed on first read, per-phase reduction stagers are prebuilt like the server's stagerByPhase (killing the per-phase flatMap and its reductions ?? [] empty-array mints), and Diagnostics.runWrapped writes a pooled verdict object — the ~6-7-alloc-per-system-run mint chain drops to ~0 and the 120-no-op-system spine bench (runtime/client-spine, new) goes 146.8µs → ~11µs/tick as measured during the hunt (loaded box); the #12166 adversarial re-derivation measured the identical paired bench at merge-base vs head on a quiet box as 79.2µs → 21.4µs (~3.7x) — the win is real, the headline ratio is measurement-conditioned. LastReductionStager memoizes its Proxy + boundFnCache (stable world identity for reduction-declaring systems across ticks — pinned by reduction.test.ts) and takes a prebuilt Set for the reducible-membership probe. Timer dispatch drains a (dueTick, id) binary-heap index (tome/timer-heap.ts; armTomeTimer is the one arm gate) instead of scanning every armed timer per tick — cancels/reaps stay map-only deletes dropped lazily at pop, holds/spills re-queue under their original key, fire order stays byte-identical ((dueTick, id) pins added to timer-dispatch-budget.test.ts), and the 5k-armed idle-tick scan collapses 6.1µs (reconstructed-old) → ~70ns (~90x; new bench case) — the #12166 re-derivation measured the real old scan at 88.8µs on the merge-base tree, so the paired ratio is ~897x, an order better than the reconstruction suggested. Small kills ride along: the server's per-tick sharedCtx/noop-jobs/placeholder-rng mints hoist to module/runtime scope, sim attribution keys per-kind maps by raw name (no per-sample NUL-template string), the client sim-step timing window becomes parallel Float64 rings (no per-tick sample objects, no Array.shift), and the worker's controlled-entity change probe compares retained expanded lists via scratch arrays instead of minting fingerprint strings per frame.

  • Named residuals: a dead owner's timer now reaps at its dueTick (dispatch's hasEntity gate) instead of eagerly per tick — closure retention until due is bounded, and the spill breadcrumb's armedTotal counts such timers until then (log payload only). Reduction proxies are stable across ticks by design — the identity-per-tick pin in reduction.test.ts flipped to a stability pin. Static-body rotation ordering (ledger 2028, savi filing 61538a0c / Streetwork): under replication an entity's components arrive as individual rows, and chunked bursts + flood-budget re-delivery put tick boundaries anywhere in the row set — so a static body can materialize from its physics/body-config row before its transform/world-rotation row arrives, born wearing identity. The dig's churn repro (adopted here) shows current master converges those orderings, but only through a stack of four mutually-covering accidents (the write-back's cache-unchanged guard, the #550 transform-replace compare, the state re-align before dispose, dispose clearing caches). This change replaces the accident stack with an invariant: a static body's rotation is a pure function of its replicated WorldRotation — physics never authors it, and every (re)materialization derives from it.

  • initializeBodyState: statics read WorldRotation (the author row) before PhysicsBodyState.rotation (the physics echo) — the previous precedence rebuilt statics at whatever stale orientation the last write-back caught unless the caller remembered to re-align state first. Dynamic/kinematic precedence unchanged (physics owns their rotation; state stays first). Engine-managed statics with no WorldRotation (terrain chunks, tilemap colliders) keep their state-seeded behavior.

  • applyStateToComponents: statics no longer write WorldRotation back to ECS. The write-back used to mint WorldRotation=identity onto a mid-adoption static, occupying the author's replicated slot with an echo — upload fabric for ownership write-fights on client-auth hosts, and one component-sourced cache seed away from permanently blinding the transform-replace compare. The rotation cache still records the realized body pose (it is exactly the "realized" side staticTransformReplaceNeeded compares the author row against).

  • getOrCreateHandle (signature-change swap): a static whose colliders are about to be re-created first gets its body pose re-written from the author components (WorldRotation, feet-first translation), so the fresh colliders index at the true pose — the swap used to inherit whatever pose the body was born with, so a signature row (scale, config re-delivery) co-arriving with a late rotation re-indexed fresh colliders at the wrong orientation and re-marked the signature fresh, permanently. The swap itself deliberately stays in place (no dispose+recreate: a removed-and-reborn body is not query-visible until the next step — a one-tick hole for raycasts and CC grow probes — and terrain edits pin handle identity); character capsule-grow holds and the mesh-not-ready hold are untouched.

  • Static birth pose is author-derived end to end: the body desc and initializeBodyState both resolve a static's translation feet-first (WorldFeetPosition is the authored surface; BodyPosition is the realization echo, stale across a component-written move) — previously a static rebuilt after a component feet-move could be born at the stale center whenever the slept-pose coherence check didn't fire, and its colliders spent the rest of the tick indexed at the birth pose.

  • seedPhysicsCachesFromECS: never seeds a static's rotation cache from ECS components (that is the one writer that could make cache == component while the body wears something else, silencing the compare forever). Currently test-only; guarded so it stays safe if resurrected.

  • Suite: dig2028-rotated-static-box.test.ts — the dig's two straight-path probes (drive across the world AABB unblocked / into the oriented face blocked), the staged churn repro (config-first adoption → reap → re-adopt, rapier cuboid halfExtents + body rotation inspected at every stage), and the signature-co-arrival poisoning case. The no-phantom-mint assertions are red on master before this change.

Named residues, deliberately not built here: (a) the synthetic feet-delta contact.normal savi burned probe cycles on — already eliminated by the fabricated-contact fix (ledger 2022, #12153, landed); (b) the owned-entities write-fight (~38k fenced rows in the filing room — netcode/command-dispatch vs the hosting client over tome/owned-entities) — the environment that tears adoptions mid-entity and can fence the rotation row out entirely; rows 2016/2017 ownership family. When the rotation row never lands in ECS, no physics-layer fix can dress the body correctly — this change guarantees the body converges to whatever the author row says the moment it exists, and that physics never fights the author for the slot.

  • Terrain textureScale adopts meters-per-tile (the object-material semantics). The heightmap material pack conversion (features/terrain/config/factory.ts) previously computed shader repeats-per-meter as 0.4 × textureScale — a repeat-frequency multiplier inverted from object materials' "meters each tile covers", ~1000× denser for the same small value; the knob-name collision burned a creator world authored at 0.05–0.08 (thread 1785625835). Now metersPerTile = textureScale (clamped ≥ 0.001), shader repeats/m = 1 / metersPerTile. No stored-value conversion (jacob's ruling, ts 1785626860). Defaults and tile-only materials pack byte-identical values to pre-flip; savi docs (api-reference §TerrainDef, heightmap-terrain skill) carry the flip note + conversion.
  • Held keys no longer survive focus/mode transitions (row 2480, the phantom-movement strand). raw-capture's overlay gate (shouldCaptureKeyEvent) drops keyups the instant the input mode reaches "overlay", so a key pressed mid-stride and released over the chat box never delivered its KEY_UP — the sim's keysDown kept the key and the character self-walked until something else happened to fire a RESET. Document blur could never be that something: the overlay runs on proxied events and the iframe keeps focus. The sweep now fires at the CAPTURE transition itself — raw-capture subscribes to input-mode changes for the life of the capture session and runs the same silent RESET the blur/visibility/text-entry-focus seams use (no synthesized release edges, so no activeOn:"keyup" binding fires from opening the chat; a key held straight through the overlay stays dead until re-pressed). tab-held deliberately does not sweep: keyups still capture there, and a Tab chord mid-stride must not stop the walk (#13173's tab-timing lane).
  • God-mode exit clears held input, mirroring the entered edge. applyWorkerGodModeExited now runs the same clearInputState the entered edge always ran — keys held while flying the god camera no longer walk the player the instant control retargets.
  • The gamepad overlay park collapses into the transition sweep. parkGamepadForOverlay's own RESET + axis-cache drop were the per-source copy of exactly what the sweep now does at the transition; the poll can't reach overlay mode with un-swept held state (state only accumulates while the poll emits, the poll parks before emitting in overlay mode, and every entry into overlay runs the sweep), so the park reduces to the overlay early-return and the gamepadOverlayParked flag is deleted. Pad park semantics are byte-identical: one silent RESET at overlay entry, axis levels re-emit on exit, button edges never refire.
  • A non-takeover engine exit's unlock event is now itself a reconcile edge (dig 62093b4f, row 2479 — kent's 64s dead mouse-look, jacob's stuck moment). The pointer-lock reconcile is edge-triggered and never polls, so an engine-counted exit whose unlock EVENT landed after every relock-bearing edge had already fired — overlay flap → exit issued → mode returns to game (reconcile no-ops against the still-true lock mirror) → unlock delivers — left a lock-preferring game with dead mouse-look until the next overlay cycle. The unlock of a counted exit now reconciles: the exit was ours, the browser permits the silent re-request, and canAcquireLock re-checks desired state so an exit whose reason still holds (open cursor UI, withdrawn intent) stays out. TAKEOVER exits — touch release and parent break, which hand the pointer to another owner against desired state — are flagged in flight (takeoverExitInFlight) and keep today's behavior: their own unlock never relocks, the next reconcile trigger decides. User exits and browser revokes still arm the gesture gate, untouched.
  • The relock decision on every game-mode unlock ships to DD (probe #1 from the dig). The Lock changed edges always shipped; WHY no re-request followed was the blind spot. One [pointer-lock] Unlock decision (mode: game): … debug line names the outcome — re-requesting, gesture gate armed, authored intent withdrawn, cursor UI needs pointer (N surface(s)), takeover exit, not lock-capable, page hidden — so the live-watch query for the wedge signature closes on a reason instead of an absence.
  • mouse4/mouse5 join the input-binding vocabulary (r-c5a11032, kent's tactical shooter; tucker's independent "No thumb buttons?"). inputs.actions.*.mouse (and modifiers) accept the side/thumb buttons: token mouse4 = MouseEvent.button 3 (back), mouse5 = button 4 (forward); aliases mb4/mb5/back/forward. The internal names follow the DOM index (Mouse3/Mouse4 — input-resolution's generic Mouse\d+ lane and raw-capture's BUTTON_MASK already carried buttons 3/4), so the engine half is pure vocabulary + the nav claim below. mouse3 deliberately resolves to nothing: DOM numbering says back, gamer numbering says middle, and an ambiguous token that guesses wrong rebinds someone's weapon. Side buttons also work as held combo modifiers (MOUSE_BUTTON_NUMBERS 3/4).
  • Bound side buttons suppress browser history navigation. A game that binds mouse4/mouse5 claims the button for the session: raw-capture preventDefaults the pointer events AND the compat mousedown/mouseup (Blink keys the navigation off the unprevented mouseup) for exactly the claimed buttons — derived from the installed bindings in installInputClient, the same wiring as the gamepad right-stick claim. Unbound side buttons keep browser back/forward untouched. Known residual: the authored-realm forwarded lane (realm iframe, unlocked pointer) does not yet suppress; pointer-locked play — the tactical-shooter shape — delivers natively and is covered.

Engine v5.2.29

Released August 17, 2026

  • Big long-lived worlds no longer melt down in multiplayer. When a world grew past an internal size ceiling, our background room helper was refused entry over and over — each attempt paying a huge download — and all that churn kept disconnecting the actual players, resetting them to spawn and timing out saves. The helper now joins the cheap way players already do and streams the world in behind the scenes, and when something does fail to join, it retries politely instead of hammering the room.
  • Savi's helpers and the room's background host now enter big worlds the cheap way players do. Instead of downloading the entire world just to get in the door, they join instantly with the essentials and stream the rest in behind the scenes — so a large world's helper shows up in seconds instead of paying a giant download at the doorstep.
  • A player whose game freezes for a while (heavy tab, background throttle, giant world load) now snaps to the room's CURRENT state when it thaws, instead of replaying minutes of stale multiplayer history — a window during which their client silently dropped other players' actions.
  • Remote players and NPCs recover to their live positions faster when a busy room backs up. When chat and world updates interleave with movement, a slow device now skips straight to each mover's latest pose instead of replaying a backlog of stale ones.
  • Bots and props no longer freeze or snap backwards for some players when the room's host changes. During a host handoff the engine used to throw away the outgoing host's last updates on players whose view of the room was a beat behind — now it holds them and applies them the moment the handoff settles, so everyone sees the same world through the switch.
  • Saves and loads that run when a player joins no longer fail during the room's first moments. Every boot had a short window where the room was still connecting; a script reading its saved data there got errors and fell back to defaults, and saved terrain edits waited on a retry instead of loading cleanly. The engine now holds those reads until the connection is ready and runs them immediately — and if the room genuinely can't connect within 30 seconds, the script gets one honest error saying the call was never sent, so retrying it is safe.
  • When the engine slows something down on purpose, Savi can now see exactly what and why. If the engine parks heavy shader work to keep a session playable, that shows up as a named fact — which safeguard engaged, how much it's holding, since when, and whether it will come back on its own — so "why does my game look different on that phone" gets a straight answer instead of a mystery hunt.
  • Five more engine safeguards now explain themselves instead of looking like bugs. When a game runs slow because the device can't keep up (dropped sim ticks), when multiplayer movement snaps to the latest position under backlog, when terrain is slow to build on an overloaded device, when a run_script times out, or when textures unload under memory pressure — each now shows up in Savi's health check as a named fact with the real cause. The run_script one matters most: a timeout on an overloaded host now says "the host is overloaded — your script is not proven at fault" instead of wrongly blaming your code for a busy loop.
  • A UI whose script library outgrows the mount budget now tells you, instead of silently keeping the old UI alive. If your ui.js require closure crosses a realm cap you get one loud fault naming the measured size and the budget, the receipts read "mount dropped over cap" instead of "sent", and trimming or merging lib modules re-mounts immediately — no dummy edit needed. The module-count ceiling also rose 256 → 4096, so the byte budget is what governs.
  • Clearer answer when a script destroys a generated piece of a wall or road. Fences, walls, and roads generate their segments automatically; those segments disappear (and come back) with their parent. When a script tries to destroy one that's already gone, the engine now explains that instead of guessing the script used a wrong id format.
  • Scripts that touch an object living in another area now get told exactly that. Writing to an entity that lives in a different place used to fail with a generic "not found" that read like a typo; now the error names the entity's real place and states the rule — writes only apply within the calling place — so you stop hunting for id typos that aren't there.
  • Animation channels playing your authored clips now report how long they run. getChannel().duration used to read null forever for authored clips — finished never fired and progress sat at 0 — so scripts couldn't sequence off a clip's end. All three now answer from the clip's own minted timing; a duration you set by hand still wins.
  • Model references pasted as bare Magic CDN filenames now just load. A ref like public.aHR0….glb (the canonical import filename without the /cdn/ prefix) used to sit as a permanent placeholder while the scene note claimed the asset host had declared it dead. It now fetches as the identity it names — and references that genuinely can't resolve say so honestly: "no request was ever made", never a fabricated death verdict.
  • Savi can see broken shaders, looks, and script errors in multiplayer worlds again. When a look, material, or texture script fails on someone's screen in a multiplayer room, Savi now gets the actual error (in her logs and as a heads-up message) instead of nothing — so she fixes your one-line bug instead of chasing a phantom engine problem.
  • Savi can no longer mistake a silently frozen game tab for the live world. When a tab's connection looks alive but has stopped receiving updates, her scene captures now carry the world version they were rendered from, so she says "your tab is showing an old version — reload to catch up" instead of describing the stale frame as current and rebuilding things that already exist.
  • A silently frozen game tab now heals itself instead of gaslighting the session. When a tab's world stops updating while its connection looks alive (the "everything I ask Savi to build never shows up" freeze), Savi's tools now detect the frozen version, save a diagnostic snapshot of the tab, and quietly direct it to re-sync — or, if that fails, to rebuild its game session in place — instead of describing a stale world as current and rebuilding things that already exist.
  • Objects stop silently going missing — and destroyed ones stop haunting the screen. When a create, destroy, look change, or effect got lost on the way to a player's screen, that player used to see a permanently stale world until they reloaded: new buildings invisible, destroyed enemies still drawn, finished effects fogging the screen forever. The engine now compares what the screen holds against what the game says exists, every second, and repairs exactly the difference within a few seconds — both directions, without touching anything that's healthy.
  • Edits and heals now stick in empty rooms: when Savi (or you) fixes an object and saves, the fix survives everyone leaving and coming back — the room no longer resurrects the object's old position or state from stale multiplayer history at the next join.
  • api.getPlayers() can no longer be broken by tag writes. A script that overwrites or wipes a player's tags used to make that player vanish from getPlayers() (while they kept playing, visibly in the world) — and a non-player session body mis-tagged "player" could show up in rosters and counts. Player enumeration now reads the platform's own record of who each session is, so getPlayers() always returns exactly the connected players — whatever your scripts do to tags. tags: ["player"] queries still work exactly as before for your own gameplay logic.
  • Long play sessions on phones stop eating memory until the game dies. Every shot, hit, and pickup sound used to leave a little audio machinery behind forever, and long iPhone sessions grew until iOS killed the tab. Finished sounds now clean up after themselves, and several behind-the-scenes buffers that were sized for desktops now use phone-sized versions on phones.
  • Long music tracks stream instead of sitting fully decoded in memory. A track past ~43 seconds — or a looping one past ~22 — now plays as a stream with a tiny memory cost, at worst a small seam when a very long loop wraps. Short loops and stems still loop seamlessly, exactly as before.
  • Less wasted work every frame. The engine stopped re-sending unchanged particle, terrain, decal, and lighting data to the GPU every frame — particle-heavy and effect-heavy scenes get that budget back.
  • Voxel worlds with heavy terrain generators no longer grow holes and see-through ground around the player. Chunk builds that used to be silently thrown away after 10 seconds — because the engine bundled far-apart chunks into one oversized job — now build one chunk at a time and land.
  • Voxel worlds stop pinning the sim on chunk install — a walk-found regression, fixed same-day. The previous cut (5.2.28, never shipped to players) made voxel worlds burn most of their simulation budget on bookkeeping every time a terrain chunk streamed in, so just walking across a voxel world could grind the whole game. Our own engine walk caught it the same day, and this cut carries the fix — chunk streaming is back to costing what the chunks themselves cost.
  • Math.floor and friends are exact again in scripts and behaviors. Math.floor, Math.ceil, Math.round, and Math.trunc now return exactly the number you'd expect on big values — Math.floor(334350000) is 334350000 again, not 334350016. Integer math, indexing, and anything built on flooring big numbers works the same in your script files as it does everywhere else.
  • Numeric constants in your scripts are exact now — physics constants survive to the last digit. 6.674e-11 is exactly 6.674e-11 in behaviors and required modules, 0.1 + 0.2 is 0.30000000000000004 like everywhere else in JavaScript, and a constant compared against the same constant from another file is byte-identical. Your source numbers are never rounded behind your back anymore.
  • First-person and aim-over-shoulder games shoot down the crosshair on phones. Tapping the fire button used to send the shot toward your thumb instead of where you were looking; the crosshair is now the aim on touch exactly as it is under the desktop pointer lock. Cursor cameras (pointerLock: false) still aim at the finger.
  • Your character stops walking when a chat box grabs the keyboard. Before: holding a movement key at the exact moment any text field in your game took focus (a chat composer opening on Enter, for example) left the character walking in that direction forever — the release landed in the text field and the game never heard it. Now the engine clears held keys the moment a text field takes focus, the same way it already did for the platform's own text fields. Any script watchdogs you built to zero stuck movement when your chat opens are no longer needed.
  • Savi's builder body no longer materializes inside your face. In first-person games her body used to appear exactly at the spawn point — inside your camera — every time she came to work, until a correction kicked in seconds later. She now arrives a polite conversational step away, already facing you.
  • Asking Savi to replace the world now works in multiplayer rooms. "Start over from the 2D platformer starter", "wipe this and make it a UI game" — these used to fail in multiplayer with an authority error while working fine solo. Now they work everywhere; other players in the room still can't trigger a world wipe from their side.
  • Shader materials the engine parked for performance now always find their way back when the scene calms. Before, if frames dipped right after the engine re-enabled a parked material, that material stayed on its plain fallback until a full page reload. Now the engine just waits longer between retries (doubling up to about a minute of steady frames), so a scene that calms down gets its look back on its own. And lag from game logic — lots of moving objects, heavy scripts — no longer counts against a material being probed back; only real GPU cost does.
  • Players whose device genuinely can't run Spawn now hear the truth instead of "Update your browser". Phones and computers whose graphics are below Spawn's floor used to be told to update a browser that was already current — forever. Now they get an honest answer: this device can't run Spawn, here's what's worth trying. Machines where a browser setting is the real fix get the setting steps instead.
  • Phones and browsers that can't start 3D now get an honest answer instead of an endless reload loop. When a device's graphics can't run a game, the loading screen now explains what's going on and what to try — before, it said "reloading usually fixes it" forever on a device where reloading never would.

technical notes

  • Past the 32,768-heads join wall, the room-host seat now joins on the player lane's scoped bootstrap instead of dying at the budget refusal forever (ledger 2198 — abbi/Animalia, the fifth walled app and the first a creator felt). The typed budget refusal (RelayIngestVerdict fatal-join) now carries headsWall: { heads, budget } structurally; when a receive-all pinned dial (room-host shell / wisp / instrument) draws it, the runtime latches a per-room scoped fallback: the redial's hello drops the aoiRecv=all rider (grant kept — emission stamping stays armed; never-declare kept — the relay's 10s declare-timeout backstop must owe the seat the FULL drain), so the join is the scoped globals-only bootstrap players already ride in prod (p50 21 heads rows vs the ~35k-row/4.4MB unscoped drain that died at the wall), the seat goes live in seconds, and the backstop bulk-loads the withheld world paced and bracketed. While warming, the session's aoiScope() answers a REAL coverage-gated claim instead of the pin's null — a partial-world host must answer world-sync only for bracket-receipted places (a false coverage claim reaps peers' entities); the "*" backstop receipt flips it receive-all in fact. Under-budget rooms never see the refusal and never latch — byte-identical joins, pinned by test. One loud structured line (room.relay_join_budget_wall_fallback) names the world-size fact for the shell/savi: the room outgrew the join budget, nobody's code is at fault, and no reset heals it while heads ≈ world size.
  • A1: consecutive relay JOIN failures earn exponential backoff with jitter. Every welcome legitimately resets the general retry budget (first authenticated message), so a join that failed AFTER its welcome redialed at the flat base delay forever — the 1.2s no-backoff storm (986 refusals/40min at abbi's room) that ground the DO and starved the humans' sockets (153 session drops in 3.5h). failRelayJoin now escalates the ladder's exponent to the bootstrap-failure streak (which already survives socket generations and resets on join success / new room), capped at RELAY_JOIN_FAILURE_BACKOFF_ATTEMPT_CAP = 8 (default ladder ceiling ≈ 25.6s) so escalation alone never exhausts maxRetries — a walled room paces at the ceiling instead of dying terminal — and arms a ±20% jitter consumed by exactly that one schedule; every other reconnect trigger keeps today's deterministic delays.
  • A2: the bounded container-tier fallback rides IN-BAND across the private-transport seam. ?hostTier=container (r-72d60fe1 M3) was a URL decoration minted in the worker realm, and the bridge redials the fixed signed URL — so the fallback was dead code on every real client (field receipt: the falling-back warn, then 22 more relay dials). It now travels the bootId pattern exactly: lifted off the pseudo-URL into a structured connect field, sanitized in the trusted realm (only the literal container ever rides), re-appended to the signed dial.
  • Join-scope semantics: every seat class now declares what its join fetches (the move-2 SCOPE half of "no seat drains O(room) at join"). The seat classes are derived from the code's three connect shapes and mapped in one home (_entry/client/relay-join-scope.ts): the PLAYER dial's scope is "place" (declared place heads + global heads now, other places on declare-place demand — the prod-proven p50-21-rows bootstrap, unchanged); the pinned receive-all-destined seats (room-host shell, helper walking sessions, wisps, instruments) declare "world-bootstrap" — the scoped globals-only join plus a full "" bulk drain the relay owes IMMEDIATELY, bracketed and paced through the per-DO drain queue. This generalizes #13022's heads-wall fallback (ledger 2198) from a refusal-latched exception into the scope the seat requests at every join: cheap join, live in seconds, world bulk-loaded behind the live flip, with aoiScope() answering the same coverage-gated claim while warming (the "" receipt flips it receive-all in fact).
  • The protocol addition is version-gated (the versions law — old pins keep receive-all). The request rides the hello's aoiRecv: "bootstrap"; the relay's aoiRecv decode is a fail-closed whitelist, so the client sends it only when the hello-invite adverted accepts ≥ RELAY_INGEST_ACCEPTS_AOI_BOOTSTRAP (the new rung 3). Below the rung — old relays, URL-advert dialers — pinned seats keep aoiRecv: "all" and the legacy full in-join drain byte-identically, and old pinned engines keep it forever. One room-scoped exception: once a room's seat rode any scoped bootstrap (requested, or wall-latched), a later generation meeting a pre-bootstrap invite (relay rollback mid-room) drops the rider entirely — the #13022 fallback hello shape — instead of claiming receive-all over a possibly-partial world.
  • The no-places-world caveat, declared honestly: in a world whose heads never tag places, the global set IS the whole world — scope cannot shrink that fetch, only declare it truthfully; the drain half's paging (#13051 — paced heads drain, both join-size refusals deleted) is what carries its size. The two halves compose: scope shrinks the fetch, paging makes any fetch safe at any size. This half deliberately leaves the drain internals and pacing untouched.
  • Relay side: relay.aoi_bootstrap_drain (info) names the owed drain as the seat's declared scope working as designed — never the backstop's wedged-client warn; the debt rides the same settled-only-after-drain attachment bookkeeping, so an eviction between enqueue and drain restores still-owed and the wake-time backstop covers it (over-delivery, never blindness).
  • Chunk-coord lookups ride a writeVersion-memoized index — the 5.2.28 voxel sim regression (both backends) dies at its multiplier (#13130; tucker's 08-17 walk, thread 1787004469). The resident-grid deslop (c2accb5bd6 — in 5.2.28's cargo, not in 5.2.26) made every chunk install re-derive base (+) journals for the owner and its 26-cell halo — 27 getTerrainChunkEditsState calls per install — and that function's "rare" fallback, findTerrainChunkEntityByCoord, is a full scan of EVERY TerrainChunkKey entity with a raw PlaceMembership world.get per entity. The fallback fires for exactly the common cases of a streaming walk: an un-edited chunk (canonical entity exists, no TerrainChunkEdits) and a not-yet-resident halo neighbor (no entity at all) — tucker's walk traces put the sim worker 94% busy on Rapier / 71% on Mantle, ~81% inclusive under terrain/client-build's install lane, with EntityWorld.get alone at 40.9% self-time. The fix removes the scan's preconditions instead of gating its callers: findTerrainChunkEntityByCoord answers from a coordKey→entities map memoized on the TerrainChunkKey store's writeVersion (the place-membership-read.ts pattern; buckets in lexicographic EntityId order so every pre-index tie-break — edits-carrier first, then canonical, then first fallback — is unchanged); getTerrainEntityPlaceId rides the memoized readEntityPlaceIdRaw instead of a raw per-call world.get; and voxel-field's journalOf delegates to getTerrainChunkEditsState (call-time, not init-time — the module sits on an import cycle with edits.ts) instead of duplicating its body. Red-first: the new chunk-identity.test.ts perf pin (5400 miss lookups over 2000 chunk entities — the halo-at-the-frontier shape) measures 1137ms on the pre-index code and fails its 500ms bound; indexed, the whole 8-test file runs in 21ms, and the 7 semantics tests pass on both shapes.
  • Relay-tier ingress overflow self-heals by AGE — a thawing client abandons a stale queued replay for the F10 fresh fold (fix 2364, dig 60e20d09 lane 2). The kernel tier self-heals a stalled simulation at 64 queued StateDeltas (room-runtime.ts handleIngressOverflow), but relay peer frames never touched that counter: pushRelayPeerFrameIngress rows had no cap and no heal, so a client whose sim stalled for minutes thawed into the ENTIRE queued replay (field specimen: 11.7k rows ≈ 3.5 minutes of arrivals applied in one gulp) — re-adjudicating every peer fact from a stale world and generating not_owner / stale-host-stamp drops at peers the whole time. Now every ingress drain checks the OLDEST queued relay.peer.frame arrival stamp; past RELAY_INGRESS_STALE_REPLAY_MAX_AGE_MS (30s — under one relay journal self-compaction window at the arrival rates where receive-side backlogs form: JOURNAL_SELF_COMPACT_ROWS = 2048 at the specimen's ~55 payloads/s ≈ 37s, and above the election's own 20s despawn-grace staleness tolerance) the queued replay is dropped wholesale and the session ROUTES to the existing redial-fresh machinery: prepareFreshRedial parks the failed-resume posture (cursor 0, keyed outbox pruned — now the shared home with handleWelcome's F10 verdict) and the socket closes RELAY_REDIAL_FRESH_CLOSE_CODE for the ordinary reconnect ladder, whose fresh dial claims resume=0 and receives the keyed-heads fold; a stall that outlived its socket skips the close and lets the already-running ladder redial fresh. Age, not rows: motion already coalesces latest-wins per (author, stream, cell), so a deep queue of DISTINCT keys is honest load and drains normally — coalesce always, expire by age, and the bound is its own backoff. Deliberately never failRelayJoin: a routine self-heal must not count toward the container-tier bootstrap fallback. The kernel tier's 64-cap heal is untouched.
  • The relay ingress coalescer keeps latest-wins under mixed traffic: barrier-watermark + tombstone supersession replace the per-barrier index clear (fix 2365, dig 60e20d09 lane 2b). The live-motion coalescing index (room-runtime.ts pushRelayPeerFrameIngress, #11733's receive-side latest-wins) was cleared whole by EVERY non-motion ingress row, so interleaved chat/state/journaled traffic stranded one more stale motion row per stream per barrier — exactly the restatement-storm shape where a slow drain needs the queue to hold one row per stream, not minutes of superseded poses (abbi's session: 2.48M promoted rows fanned out uncoalesced into an 11.7k backlog). Now non-motion pushes advance a barrier watermark instead of clearing the index: a queued motion row indexed AFTER the last barrier still replaces in place (byte-identical to before), and a row stranded BEHIND a barrier is tombstoned in place while the fresh restatement lands at the tail — remove-old + push-at-tail, the same rule the relay's own join buffer applies on the fan-out half (fanout.ts), so no pose ever applies ahead of a journaled row it followed on the wire and the queue holds at most ONE motion row per (author, stream, cell) regardless of interleaving. Pure-motion streams, catchup-phase replay (never coalesced — history, not restatement), and the drain/teardown/compactor index lifecycle are unchanged.
  • Nobody-stamped frames park, never drop (tide's ruling generalizing tucker's fold-never-drop veto; envelope-dig lane 3, r-b035693c). The relay host-stamp gate (relay-peer-apply.ts translatePeerStateDeltas) used to drop a live host-stamped message whenever this fold's replicated table disagreed — including when the table named NOBODY (mid-election, or a fold behind the seat fact), which manufactured desync: a deposed/incumbent host's last hosted-remainder truth (bots, props, NPCs) vanished on exactly the observers whose fold was behind, unrecoverable if the host went quiet. The unadjudicable case now PARKS (relay-host-stamp-park.ts): frames wait keyed by the sender's session until the stamp becomes adjudicable — the election fact folds and the table names someone, either seat, either verdict — then re-enter the same gated pipeline (a confirmed stamp applies ahead of the sender's next live frame; a refuted one refuses exactly like a live straggler). The quiet-stamper case drains from the netcode ingress tick sweep. A table naming ANOTHER client keeps today's refuse — that verdict is real and LWW-corrected. The park's bound is the session: the membership tomb (and the late-join orphan sweep) reaps a dead sender's park whole; there is no TTL and no eviction, and a park that grows past the sanity line (256 frames) confesses on the governor channel (host-stamp-park, why-class backlog) instead of silently ballooning.
  • The paged join's fold-completeness fact is readable: the budgeted ingress drain publishes its deferred backlog (ClientNetcodeIngressBacklogResource), and the relay e2e harness's settle() holds until every stack's fold completes (the shard-1 CI intermittent, runs 31974390857/31979604866). The completeness gate lives at ONE place — applied-through-catchupThroughSeq, the welcome's single ceiling — and pages are INTENTIONALLY partial until the cursor reaches the ceiling (#13051 kept one welcome/one ceiling as the sole settlement truth). Every presence-shaped proxy is wrong by construction: worldBaselined flips at the self-spawn edge BEFORE the drain finishes (relay-join.ts maybeFinalize, grant-before-drain), and the test harness's settle() keyed "done" on hub quiescence, which goes silent the moment the relay finishes DELIVERING — while the #12973 budgeted drain (8ms/tick, 128-message floor) is still APPLYING the fold across ticks. On a contended CI tick the wall-clock budget expires inside every tick's minimum batch, the fold pages, settle's quiet-exit fired mid-fold, and the terrain-compaction test read partial heads — undefined where the last-seq head (cell 0's dirt overwrite) hadn't applied yet. Solo it passed by a zero margin: 561 rows against exactly 5 quiet-exit ticks × 128. Now the drain stamps its carry remainder (on change only — a never-deferring world never writes), the harness exposes it per stack (ingressBacklog() = transport queue + deferred carry), settle() counts backlog as work, and the terrain test pins the paged shape deterministically (700 cells > 5×128, ingressDrainBudgetMs: 0 = the contended-runner model). The ingress-budget suite's hand-rolled carry flush ("settle's quiet-exit cannot see a stack-local backlog") is deleted — the gate sees it now. Engine behavior on the wire is unchanged; production terrain already converges reactively as pages fold (client-terrain-system remeshes in the tick an edit lands).
  • GameDB door calls PARK through wire-not-send-ready windows instead of burning (ledger 2381, dig a04bde80). Every relay boot has a deterministic window where the world ticks — and onPlayerConnected fires — before the wire session reaches live (relay-wire-session.sendRpc refuses until then), and sendDoorCall was fail-fast: a creator's api.sql at boot burned its bounded retries sub-second and landed on defaults (Duskfall's progress table, 3 tries at ticks 16–18), and the engine's own terrain-edit restore (_spawn_chunks row census) died on the same window on every relay-hosted authority (the headless room_host shell, the SP client authority on a relay wire). A PRE-send refusal now parks at the door (game-db-relay.ts): nothing crossed the wire and nothing executed, so the park is replay-safe by construction; the tick sweep drains the park FIFO the moment the session opens (the reply clock starts at the SEND), and a wire that never opens hits a bounded 30s ceiling (RELAY_GAME_DB_PARK_MS) and dies typed with the one verdict a post-send timeout can never offer: this call was NEVER sent — safe to retry. Post-send failures keep the fail-fast contract untouched (the park is not an outbox; a call that crossed the wire is never re-sent), calls issued while anything is parked queue behind it so the window never reorders, parked calls count against the in-flight cap, and a room reset answers them with the never-sent honesty. The engine's terrain census and the room-state resume ride the same park unchanged — the boot read now lands on the live edge instead of warning and waiting out a retry cadence.
  • Governor confessions: engine governors that reduce work now confess it as a structured fact (r-2fe97ac9; the r-da0b1dbe governor-legibility class). New channel engine/renderer/governor-confessions.ts — a per-worker ledger (the diagnostics-sink pattern) where a governor reports engage / level-change / release with a closed why-class vocabulary; facts carry which governor, how much, since when, and why. State changes dedupe, the transition ring is rate-bounded, and a session where no governor ever engages pays nothing (the snapshot is null and every surface omits the block — absence IS "nothing governed"). The renderer worker's ledger rides the existing 1 Hz perf-sample relay; the sim worker's client-health reporter merges it with its own ledger into a new governors block on the engine.clientHealth snapshot (server-side re-validated: why-classes/events allowlisted, strings sanitized, arrays bounded), and api.getClientHealth() answers with a per-client governorSummary line plus the full block. First wired face: the frame-budget park machine — parks, staged unparks, futility restores, and probe re-parks all update one fact derived from the park registry itself, with the distinction that survives ledger 2353's probation redesign structural instead of prose: parked materials auto-probe back (parked/refs), a parked look that delivered never auto-returns this session (look: "parked"). Build-failure parks never confess — a broken script is the script's story, not a governor move.
  • Governor confessions: the five remaining governor faces confess (r-da0b1dbe residue; #13040's channel, no new plumbing). Every face is one confessGovernor call at its own shed/pace/kill site, folding into the existing governors block on engine.clientHealth (sim-worker ledger directly; renderer-worker ledger via the perf-sample relay). (1) Tick shed (sim-health.ts): the ticker's backlog clamp confesses shed count + shed ms + the cause window as host-overload, releasing after a full clean window — bounded slow motion stops reading as "the game is slow for no reason". (2) Relay ingest (relay-ingest-confession.ts, judged at room-runtime's drain edge; ledger 2331's 13k→48k unbounded-queue dump): latest-wins motion coalescing volume or raw queue depth past their lines confesses backlog with the measured counts, releasing after a clean window. (3) Chunk-build pacing (client-terrain-system.ts; the ledger-2012 starvation class): liveness-starved chunk builds (stuck/canceled — no job throughput) confess backlog derived from the failure registry every tick; deterministic build-error failures and terminal parks never confess (the content's story, the build-failure-park rule). (4) Exec timebox (tome/exec/host.ts; ledger 2325): a watchdog kill now measures the host's own tick cadence while the exec was in flight — the watchdog's starvation is the evidence — and confesses host-overload/"host-overloaded" (avg tick gap ≥ 100ms) vs rate-cap/"script-exceeded"; the caller's error string carries the same verdict, so an overloaded host no longer teaches "likely a busy loop — split your work" for innocent scripts. Released when the next exec settles. (5) Texture residency (lume/assets/service.ts): keep-alive-budget evictions confess memory-pressure with cumulative pressure-eviction and thrash-reload counts (page-derived housekeeping retirements never confess), releasing after 600 under-budget frames.
  • Over-cap authored-UI mounts confess and heal instead of dying silently (row 2399 — derrf's Loop Machine, incidents 44324169/761406fa/9e47956e). A game-HUD/creator-panel lifecycle mount ships its entry module plus the whole require closure to the sandboxed authored realm, and the realm channel guard refuses any mount over REALM_SIZE_CAPS — correct against hostile traffic, but the refusal was a frame-side silent drop: the worker's receipts read "sent", lastMountKey advanced, and the previously mounted code stayed live across reloads while the creator's lib grew underneath it (262 modules crossed the old 256 sourceCount cap at under half the byte budget; the blank minute was the crossing). Four moves: (1) the worker controller preflights every mount with measureLifecycleMountCaps — the guard's own predicate, exported from realm/protocol.ts so admit⇔accept parity is pinned by test — and refuses loudly on the fault rail (ui.mount, once per mount key) naming the binding cap, the measured closure (count/chars/largest module), and the fix; (2) the HUD pipeline ledger carries a lifecycle-delivery verdict (sent / dropped-over-cap with reason), so receipts can no longer read "delivered" for a mount that never ran, and the debug-dump summary prints it; (3) a refused or send-failed lifecycle command never advances the resolver's cursor (the game mount key / creator panel id commits only after postMessage returns — the lastSignature refusal, mirrored), so shrinking the closure below the cap mounts on the next sample with no unrelated edit; (4) sourceCount rises 256 → 4096 — it is a decode-abuse ceiling, and the byte caps (1MB/module, 8MB total) now always bind first for real module graphs.
  • A missing __spline__ child id names its root instead of blaming "array indices" (ledger 2338's secondary wart). api.query legitimately returns spline/scripted expansion children (<root>__spline__…) — they are real, measurable, tag-bearing objects — but their lifetime is coupled to their root: a root destroy cascades them and the apply pass re-mints them, so a query-then-sweep destroy loop reaches ids that died with the root moments earlier. Both zero-match mouths (the behavior warn rail's emitMissingTargetTeach and the exec seat's describeMissingDestroyTarget throw) told that loop not found — call ignored. Use entity IDs (e.g. "my-object"), not array indices — a wrong theory about an id the engine itself handed out (Denmark By's script noise, 08-15). Both mouths now detect the generated-child marker (inferSplineGeneratedOwnerId) and answer with the coupling: root live → "generated children die (and re-mint) with their root — destroy the root instead"; root gone → "this child died with its root; generated ids never need their own destroy call".
  • Cross-place mutations confess instead of silently no-oping (r-c79a5004; the sumo-volleyball "engine limitation" specimen, mavi's dig 4fcf89aa). A script in place A mutating an entity in place B no-opped with the generic missing-target teach ("not found — call ignored … not array indices") — a wrong-cause verdict for an entity the spec provably declares in another place; creators built workarounds around the silent drop and Savi built typo theories at authoring time. The miss rail (emitMissingTargetTeach — one seam under setObjectProperty, patchObjectState, deleteObjectState, the physics verbs, …) now checks the unfiltered+effective spec view: a target declared under places.<B> while the caller stands in places.<A> gets the structured verdict — "cross-place write dropped: <entity> lives in places.<B>, this script runs in places.<A> — writes only apply within the calling place" — delivered where the writer reads (the runtime-log ring for behavior scripts, attributed to the writing entity; the exec sink verbatim for run_script). Zero behavior change on the write itself — apply semantics stay the separate design track. Conservative by construction: findSpecObject prefers the caller's place, so a cross-place duplicate whose local copy died keeps the plain miss teach, and destroy never reaches this rail for spec-declared ids (its clientDestroy fan-out is the delivery path). Same-place writes and unknown-id / dead-local-id teaches pinned byte-for-byte.
  • getChannel resolves authored-clip duration from the minted library entry (ledger 2384 — Nine Heavens' unfalsifiable railDead probe, dig 1c219e7d). An authored-clip play writes only the identity STRING into the mixer channel — the minted AuthoredClipEntry (with its duration) lives in the derived AuthoredClips library, and nothing ever wrote it back into the channel — so getChannel().duration read null FOREVER for authored-clip channels (finished never fired, progress stuck at 0), on every engine version, healthy or dead. Nine Heavens latched railDead on exactly this probe and spent eight rounds of pose work building GLB-puppet workarounds around an unfalsifiable artifact. The read lane now answers what the write lane minted: when the channel's clip resolves in the AuthoredClips library, duration = entry.duration / |speed| — one lookup — and finished/progress derive from it consistently. A caller-supplied channel duration still wins, and sprite-atlas timing is untouched (both pinned in get-channel-timing.test.ts).
  • A bare canonical Magic CDN filename is now a loadable model reference, and a loader-refused id can never wear the receipted-death caption (row 2405, dig 82a978a9 — iz's charzoo m20). A spec ref of public.<b64>.glb — the canonical tagged-import identity minus its two-character /cdn/ prefix — was refused by startLumeModelLoad (not in manifest, not URL-shaped), and the refusal then flowed through getUnreadySceneAssets as failed, whose scene-note clause teaches "the asset host itself answered a terminal verdict … a receipted death, not a guess" — for a request that was never made, about a file that was serving 302/ready the whole time. That caption sent Savi and two digs hunting server-side status doors. Two halves: (1) the primitive — synthesizeModelUrl extends the loader's existing URL-shaped synthesis to bare canonical public.<b64>.<ext> filenames (shape-tested by BARE_CANONICAL_MAGIC_CDN_FILENAME; the kiln grammar owns validity, a malformed payload earns the CDN's own teaching 400), used by both startLumeModelLoad and resolveModelLodId, so the two-missing-characters failure class stops existing; (2) the truth surface — ids the loader still refuses (neither URL-shaped nor canonical-shaped) report the new unfetchable status, whose clause states the actual truth: "not a fetchable reference … no request was ever made, so nothing failed and nothing is generating," with the /cdn/ prefix teach. The refusal console line now says the same. (Companion kiln change, same PR: the geowire fail-open row marker rewords failed-open:<reason> → degraded-serving-original:<reason> — the bookkeeping bits stay, the death-prime on a serving row dies.)
  • Renderer/client engine diagnostics reach Savi on relay rooms — the egress fold (ledger 2380, dig 292859c6). engine.diagnostic Commands (the scripted-look/material/texture-script fault family, renderer crash rails, mutation warns — every client-reported code in tome/engine-diagnostics.ts) had ONE terminus: the kernel server's command registry. On the relay tier — the default room host — every report died at the egress dead-lane drop (relay-command-lanes.ts lane "none") with no fold anywhere: the seam note claimed "the local runtime log carries it" while nothing did, so Savi was structurally blind to every renderer-side creator-fixable fault. The dig's specimen: five named scripted-look-compile-failed errors fired (a creator's one-line TDZ) while Savi filed a phantom engine bug twice. The row is { lane: "local", consumer: "diagnostic" } now: the egress consumes the command into the SAME validate → sanitize → dedupe → runtime-log → DM-class machinery the kernel server runs (foldEngineDiagnosticLocally), against the LOCAL client world — the ring row lands where getLogs and incident dumps read, and the error-class first-occurrence DM rides the EXISTING tome.dm.notify forward the same egress already consumes into the "dm" RPC lane (never a new pipeline; kiln's notify door holds the durable once-key dedup, and the spec-version-scoped key means N clients folding the same condition cost one Savi turn). Warning/info classes stay logs-only; storms and budgets are the kernel terminus' own. One known redundancy, accepted as honest: mutation-warn now lands twice in a relay ring (the local sink's raw row + the fold's framed copy), both cooldown-gated. Perf-category pointer DMs (notifyPerfDm) still no-op on client worlds — their log bodies now land, the hourly pointer remains kernel-only.
  • Scene RPC answers stamp the APPLIED spec version — the attached-but-frozen eye is convictable by version (ledger 2337, 2266's residue face, incident c24dc25b). The 2266 room-feed stamp moves only on TRANSPORT edges (room-feed-state.ts: connect/attached/reconnecting/detached), so a socket that stays "attached" while receiving/applying NOTHING passed every EYE-STALE gate — the field specimen answered camera-responsive identify/look RPCs from spec v2 for ~15 minutes while the room's doc head was v14, and Savi burned the window on restarts, terrain rewrites, and a false engine-fault filing. RoomFeedSnapshot now carries appliedSpecVersion — the world's applied TomeSpec.dbVersion (the same number run_script results carry as specVersion), recorded at the worker→parent telemetry relay hop worker-browser-host already owns (noteAppliedSpecVersionFromTelemetry, beside the viewport injection) — so every scene-view/identify answer names the world version it was rendered from and the tool side can compare it against the doc head it already holds. Deliberately a stamp fact, never a state edge: the transport machine is untouched (its job is transport). Absent until the first telemetry tick names a dbVersion — old engines and version-less worlds keep today's stamp byte-identical.
  • The frozen-feed recovery directive — the tab that never processed a welcome can now be cured from outside (ledger 2342, the eye-integrity family's fourth face). The 2342 class: a realm frozen at join (applied spec stuck at v2/v3) that keeps answering camera-responsive scene RPCs, restart-immune (a relay rooms reset forces fresh welcomes and the field tab stayed at v3), invisible to every transport gate (heartbeat echoes keep the silence watchdog green) and past every in-worker self-heal lane (884/945/987/1509/F3/F4 — all present on the field engine). Two faces: worker room-session ingress dead behind a live MessagePort bridge, and an orphaned realm serving eye+telemetry with no transport. The chat side convicts on the applied-vs-head stamp (ledger 2337) sustained across the engine's own five-heartbeat silence standard, captures a frozen-tab debug dump FIRST (three field specimens yielded zero), then directs recovery over the kiln page channel — the one path the conviction proved alive. Engine half (this changeset): a recover_room_feed client RPC (room-feed-recovery.ts, spawn.ts's lazy-import law). Rung 1 condemns the REAL room socket from the page realm that owns it (BrowserWebSocketClientTransport.condemn — the chunk-failure pattern: raw-socket close so the close event reaches the runtime; new CLIENT_EYE_RECOVERY_CLOSE_CODE = 4437 so the server's close row says why), converting the freeze into the existing close→reconnect→reattach→fresh-baseline lane. Rung 2 recycles the realm: the client session boot is now a named re-invocable (startClientSession), so the directive disposes the host — terminating the runtime worker and both renderers — and reboots into the current room through the same connect machinery the authored nav.room swap uses; single-flight by construction. No new detector rides the engine: the conviction edge lives tool-side, outcome-keyed, and old chat deploys simply never send the directive.
  • Entity residency reconciles sim↔renderer — the tilemap sweep (#12968) generalized to the entity forward/release lane (ledgers 2382/2338/2376). The sim→renderer entity lane has no delivery guarantee either: stream-reset recovery discards buffered frames, a poisoned reader drops queued ones undecoded, and the lane can wedge wholesale while both endpoints stay alive (2382: the renderer answered RPCs and timed frames while every create/destroy/material after some T never materialized; 77a6fe7f: 12 spec-present houses invisible until restart). A silently lost frame on an otherwise-live channel left the renderer's applied set diverged forever — spawns that never materialized, and destroys whose release never landed: 2338's orphans drawn forever, their fx populations with them (2376). Now the renderer reports its applied entity set (RenderChannelReader.entityResidencyCensus — the reader's own liveEntities, cached per membership revision) on the same 1Hz perf-sample relay the tilemap census rides, and the sim's sweep (entity-residency-reconcile.ts, run from render/ecs-sync after the delta path) diffs it against sentEntities and repairs exactly the delta: missing entities re-forward FULL live state through the include-reconcile encoder (SPAWN + every forwardable component — draw, materials, fx programs — re-derived from world truth), and sim-dead ghosts get a delete frame whose DESPAWN reaps their fx populations with their carrier (handlers/particles.ts → removeParticleEntity). Pacing is the tilemap sweep's own — same quiet window, consume-once censuses, repairs re-stamping their own ids — with one forced adaptation: the quiet gate is per entity id (last lifecycle write), because entity churn is continuous in exactly the games this class hits (2338's belt spawns every hit) and a place-wide gate would starve forever, while a per-id gate leaves an untouched orphan judgeable regardless of churn elsewhere. Deferred (never discarded) while the channel is delivering, reset wholesale on writer swap / recovery snapshot / place transition / initial snapshot, and idempotent by construction: a misjudged in-flight forward re-states identical live values (never a despawn, never flicker), so a wrong judgment costs wire bytes, not correctness. The tilemap lane's synthetic ids are exempt in both directions — its own sweep governs that seam.
  • Client debug dumps carry the applied entity set. EngineBugRenderDiagnostics.entityResidency mirrors the census (count + space-joined applied entity ids, ids clipped at 4KB on ingest with count complete; {count: 0} is a positive empty-world receipt, null = pre-instrument). The applied set in dumps was previously ZERO — 2382's "everything after T never materialized" took a full dig to establish; it is now one count compare against the sim's entity table and one grep over entities.
  • Ordinary saves retire the journal fossils they supersede — the #12821 fence's condition extended from REPLACE-class to per-key (row 2394 P1, dig c37047e0). The gap: a host's adoption restate journals every hosted keyable entity as a keyed head — a live-world snapshot — and an ordinary-write save (Savi's heal, a creator's edit) superseded the book's values for the objects it touched but retired NOTHING in the room's journal, because the replace fence keys on replace TRANSITIONS only. A static entity never sim-writes, so its stale head was never re-published; it lay dormant while the room stayed hosted, and the first UNHOSTED join bootstrapped from the healthy doc then refolded the fossil over it (iz's Northshire: 13,694 entities re-journaled at a new epoch, y=15.5 fossils clobbering the healed y=17.7). Now the save's writer names the keys it superseded: kiln's mutation funnels (game-specs mutations, spec-save mutation batches) extract the batch's touched object ids (supersededObjectIdsForMutations — O(batch), never a spec diff), the relay SPEC lane mints them into entity-key prefixes (e:{id}:, deduped, alphabet-checked, capped at the mirrored 128 with honest under-retire on overflow) and rides them as additive retire on the checkpoint marker; cf-relay range-retires LIVE client-authored heads at or behind the marker under exactly those prefixes, in the same transaction as the truncation, under the fence's proven discriminators (is_tomb = 0, author ≠ #relay) and §9.2 hold discipline. Untouched keys' heads park, never drop (runtime spawns are legitimately un-authored by the doc); heads above the marker survive (a post-save publish is newer than the doc); replace-class markers are unchanged (their fence already retires a strict superset). Old relays drop the unknown field — today's exact marker; old writers never send it. Whole-doc PUT surfaces send nothing (they cannot cheaply name a touched set) — honest today-behavior, not a guess.
  • The reflection rails (rail.emit/rail.noise) go LIVE-only — journaled history stops being re-adjudicated and stops re-firing at joiners' folds (ledger 2363; kills the invisible-players rejoin class). emit/noise are keyless journaled STATE, replayed to every joiner until self-compaction rotates them out, and their peer translators ignored the frame's phase entirely: every fold-replayed row re-ran the senderOwnsSource ownership walk against the receiver's MID-FOLD world. At a join fold the author's avatar hasn't applied yet (drop), and after the author's own rejoin it never will — avatar ids are player/<clientId> and a rejoin mints a new identity, so old rows' sourceIds dangle forever — manufacturing not-owner:unresolved drop verdicts for settled history at exactly the rejoin that most needed silence. Worse, a fold-replayed emit that DID pass the walk synthesized a live event.fwd the wire apply core fires unconditionally — the room's emit journal stacking as join-storm stimuli at every join (noise.fwd feeding the NPC perception queues the same way). The cure is the #12915/#12937 phase discipline the terrain rails already carry, at the strength the delta-events law demands for stimuli: a reflection is a stimulus, not state — its live consumers heard it when it rode — so catchup/repair rows now translate to nothing, silently (no re-fire, no ownership re-judgment: history was adjudicated when it was live; replay is not a new claim), exactly the posture translatePeerStateDeltas takes on delta.events one lane over. Live-phase rows keep the ownership gate exactly as deliberate (forged sources still refuse loud), and the sender's egress loopback stays live by construction. Pinned red-first in relay-peer-apply.test.ts; the terrain rails' phase tests ride unchanged. Design word (tide, settled): place-scope acts adjudicate by MEMBERSHIP — ownership walks are for entity-owned rails — so the live-path membership migration for reflection rails is the named follow-up, deliberately not part of this change.
  • getPlayers answers SESSION TRUTH — both faces of the tag-predicate contract die in one predicate (ledger 2350, dig 28ec3308; 2141's escaping class). Every getPlayers/getHelpers lane (behavior ObjectAPI, camera API, the exec lane's bare globals) enumerated tags: ["player"] over the per-place tag index — a GAME-MUTABLE surface. Face A: a session body wearing the wrong class tag counted as a player (the summoned builder shell impersonating a roster member — 2141's class escaping its #12572 fix). Face B: a connected creator's avatar, in-sim and answering api.query({ radius }), vanished from getPlayers for 44+ minutes because its class tag was gone/index-invisible — and "SessionOwner without the class tag" is a real engine state (spawnPlayer's own suspension-stamp defense names it) a tag predicate structurally omits. The fix: spawnPlayer stamps the VERIFIED session class (the same room_host-token-derived helper bit the tag is minted from — kernel auth.ts, relay runtime-worker.ts) as SessionAvatarClass (engine/components/session.ts): plane authoritative, clientAuthWrite: "never" (stripped from every kernel-tier upload), no ObjectAPI writer (scripts structurally cannot touch it), replicate "aoi" so it rides the relay self-spawn create and kernel AOI replication to every peer. One enumeration body (tome/session-avatars.ts → query-utils querySessionAvatarsInPlace) now serves all lanes: stamped entities are judged by the stamp ALONE (reads components directly, never the tag index — a wiped tag can't hide a player, a wrong tag can't promote a helper, a suspension-stamped avatar stays enumerable); avatars WITHOUT the stamp (a pre-stamp engine's spawn folded into a mixed-version relay room) keep the historical class-tag query verbatim, a legacy lane that dies out with pre-5.2.27 engines. Same session truth now backs resolveObjectId("player")'s active-player sweep, resetPlayerEntity's class re-mint (closing the "re-mint off already-wiped tags" escape lane the dig named on 2141), and the SP-mirror exec rebase's player population. Row shapes pinned red-first in getplayers-session-truth.test.ts, both lanes.
  • The audio decode budget is enforced at the service chokepoint — an oversized clip demotes to the stream lane before any PCM is paid (#13021, the #12517 split; 2026-08-12 device forensics). The renderer priced forced-buffered starts before decoding, but every other decode — the preload warmer's spec sweep, a JIT start's park→prefetch poll — reached decodeAudioData unpriced, so one long track held its whole PCM resident (the measured 66 MiB single-clip specimen behind the iOS WebContent kills). Two rails give the service's own paths the verdict, cheapest first: pre-decode pricing via one element-metadata probe when the encoded size is suspicious (>1.5 MiB SUSPICIOUS_ENCODED_CLIP_BYTES — past which no plausible lossy encoding decodes under budget), and a decode-time backstop so an over-budget buffer is never RETAINED (the transient decode is paid at most once; the recorded duration prices every later session before it even fetches). Pricing failure is never load failure. Either rail demotes the clip to the HTMLAudioElement stream lane the renderer would have chosen, and both are loop-aware — the estimate counts loop-conditioning's second copy — so a LOOPING clip whose decoded PCM lands between 8 and 16 MiB (~22–43 s of 48 kHz stereo) now streams with a wrap seam where it used to slip through and hold double its PCM. ensureDecodedBuffer stays buffer-guaranteed by contract and never demotes.
  • Naturally-ended voices detach their WebAudio node graph — the leak behind the residual phone jetsam kills. Device forensics (2026-08-12, iPhone 14 Pro Max) traced the remaining game-tab deaths to jetsam reason=highwater at WebContent's 1536 MB soft limit, and syslog showed the growth driver: 17,903 AudioNode constructions against ONE destruction over a 22-minute session — every combat SFX built a gain(+panner)+bufferSource triad whose natural-end path deleted the voice map entry but never disconnected the nodes, each leaked source also pinning its decoded AudioBuffer. Disconnects are now a shared per-node-guarded disconnectVoiceGraph (one throw can no longer strand the rest of the graph — a naturally-ended source's stop() throws, and the old single try/catch skipped every disconnect after it), and the natural-end onended path runs it unconditionally.
  • Constrained tiers stop paying desktop memory sizing. KTX2 decode runs ONE worker on non-desktop tiers — each pool member pins a ~17 MiB Basis wasm heap for the session, and the pipeline byte budget already bounds decode parallelism, so the second worker bought memory without throughput — and the transcoder releases its fetched wasm binary after instantiation in every realm. The fx terrain-height mirror is priced per tier (fxTerrainCapacityForTier): mobile gets 1 M samples + 512 chunk slots (4 MiB CPU + 4 MiB GPU) for the standard streaming profile it actually runs, instead of the desktop extended-horizon 16 + 16 MiB; overflow degrades exactly as designed — skipped chunks sample as "no chunk", counted in stats().overflowedChunks. And baked pose palettes (~4.6 MB of CPU pose frames per horde-batched model) are released on model asset dispose — the acknowledged F13a retention closes.
  • The per-frame upload blanket dies — unchanged bytes stop re-shipping to the GPU every frame. The fx pool's pop-table writers self-mark their own rows (markTablesDirty deleted — interface, implementation, and all four call sites), so a frame uploads exactly the rows something wrote instead of every population row at 60 Hz (~330 of a measured 348 MB/min was provably-unchanged bytes). Terrain's slot-major pool mirrors die outright: GPU uploads read the per-chunk records — the wire truth — deleting ~25 MB of duplicated renderer CPU mirrors at 1089 chunks, with one named ordering change: picking sees edited heights at op-apply instead of at visual sync, the same freshness ground sampling always had. And the decals, lighting, and scripted-arena upload sites change-gate on content — static decal stamps upload once instead of 106×/s, the lighting pass + cluster-cull uniforms merge into one buffer with settled light records gated by compare, and still entities stop restaging their matrices (UploadArena.writeIfChanged).
  • Voxel chunk batches build per-chunk inside the sliced batch loop — the fused shared-field batch mesher and its LOD group key are gone (dump a887d221, forest-carved). buildVoxelChunkBatchData sampled ONE field over the batch's bounding box; the client packer picks its 8-chunk batches by streaming priority, not adjacency, so a priority-order pick strung along a column or row (the ring's tick-7715 batch spanned 1×3×14 chunks) sampled 42 chunk volumes for 8 chunks — 5-40× the sum of its members' single builds (measured on the reporter's generator: 55 ms per single chunk, 2.2 s / 4.7 s / 10 s per batch, 27 s for a spread pick), one synchronous task with no cancel point. The client's 10 s stuck window then reaped every batch unread while the worker kept computing, resubmitted it after backoff, and the treadmill never converged: 32 chunks a session failed outcome=liveness reason=stuck on a desktop whose per-chunk cost was healthy, holes and see-through ground where those chunks should stand. Now buildTerrainChunkBatchOutputs runs every generator through the same per-chunk loop: N chunks cost exactly N single builds (byte-identical outputs — pinned), no batch geometry can multiply the work, the loop yields at the slice budget, and a cancel lands between chunks. The packer's terrainChunkBatchGroupKey LOD lanes (PR #6713's mixed-batch precondition) die with the mesher — a batch is one bounded pack of the priority-sorted selection and may mix LODs. Ronnie's session batches build in 0.4-0.5 s on the same box.
  • Math.floor/trunc/ceil/round are spec-exact native pass-throughs in the sandbox Math shim (ledger 2317 — savi-filed incident ae7bdf9e). unaryMath quantized the ARGUMENT through Math.fround before calling the native, so every integer above 2^24 changed before the op ran — fround(334350000) = 334350016, floored: Math.floor(n) === n reported false for good integers, floor-division indexing silently shifted, and an integer-only sim corrupted inside every deterministic compile unit (behaviors and require()'d scripts/lib/* modules alike — only ad-hoc run_script escaped, compiling with native Math). These four ops are correctly-rounded per ECMA-262, natively bit-identical across realms — f32 quantization bought zero determinism there — so they join imul/clz32 in the shim's deliberate native-bypass family (exactUnaryMath: ToNumber the arg, run the native, no quantization either side; the result is always an exact integer, which doubles represent exactly to 2^53). Everything implementation-approximated keeps the fdlibm table and the f32 in/out contract unchanged. Pinned on Savi's exact repro numbers, on Object.is bit-identity to the natives across the f32-unrepresentable range (±0/±Inf/NaN included), and through the real compiled sandbox. Pinned games keep their engine's math per the shim's compat law — the version boundary is the compat layer.
  • Numeric literals in authored source are exact — the f32 literal-quantize pass is deleted (ledger 2319 — savi-filed incident f4451f3f, 2317's sibling). quantizeFloatLiterals wrapped every float-typed literal (decimal AND exponential notation) in _f32(...), so authored constants changed before any code ran — fround(6.674e-11) ≠ 6.674e-11 broke byte-identity to a research sim's published physics constants inside behaviors and require()'d modules alike, while integer literals were already exempt and ad-hoc run_script escaped (the same realm split as 2317). ECMA-262 numeric-literal → f64 conversion is exact rounding, identical in every realm, and IEEE f64 + − × ÷ are correctly rounded per spec — so quantizing literals bought zero determinism anywhere; the f32 contract belongs to the sandbox Math shim's implementation-approximated RUNTIME members (unchanged: fdlibm table, f32 in/out), never to source constants. The pass, the _f32 runtime member, and its injection binding are dead; every literal spelling (integer, decimal, exponential) now parses to the same bits as native JS, in every compile unit (behaviors, lib modules, generators, UI — the UI realm was already exact, so the realms now agree). Pinned games keep their engine's math per the shim's compat law — the version boundary is the compat layer.
  • On phones, a lock-preferring camera aims from the crosshair — the touch aim pin (Jacob's shooter: "fire shoots from where I click the screen instead of the center"). The pointer transport (worker-browser-host.ts createPointerTransportHandlers) publishes every pointer's NDC to the renderer, which authors the canonical pointerOrigin*/pointerDir* ray from it each frame — and the only thing that ever pinned that NDC to (0,0) was document.pointerLockElement, which a finger can never hold. So under a first-person camera on a phone every touch — the fire chip, the move stick, a look drag — dragged the canonical ray to the thumb, and getInputRay(input) hitscan left the crosshair. The pointer-lock manager now exposes the lock's touch twin, isTouchAimPinned() = authored intent held AND no visible cursor-need UI (the desktop retention rule minus the browser: a data-modal frees the finger the way it frees the mouse, the pass-through canvas tap re-pins the way the rescuing click relocks; mode and visibility never enter, exactly as an engaged lock survives them). The transport reads it per touch event alongside the browser lock — NDC pinned to (0,0), isLocked reported, input.pointer absent — so the sim, the renderer, and getInputRay see one lock story on every device. Mouse/pen pointers and wheel read the browser lock alone (a hybrid keeps its acquire/release flow; a touch beside a locked mouse aims from the crosshair through the touch-release exit). God mode and cursor cameras arrive as authored intent false (resolvePointerLockWirePreference) so tap-select, god handles, and tap-the-world gestures keep the finger, press stamps included; pointerLock: "drag" never pins. InputState.pointer / getInputRay docs and the pointer-raycasting / camera-first-person / camera-third-person skills now say the same thing: phones follow the camera, not the finger.
  • A key held when an authored text field takes focus no longer strands — the self-walk input latch is closed (ledger 2345). The latch: press Enter while W is held → the game's composer autofocuses → W's keyup targets the focused field → the realm frame withholds it (isTextEntryTarget gate, deliberate — typing must never fire activeOn:"keyup" bindings) → the host doesn't RESET on blur-into-realm (deliberate — resetting there wiped every world click) → keysDown clears only on KEY_UP or RESET → the axis stays latched and the character walks until the same key is re-pressed or the page restarts. Two games independently built script watchdogs against this seam (Animalia's chatTyping, Death Valley's RUN-OFF GUARD). The fix is the realm twin of the host's existing editable-focus reset: the frame posts a textentryfocus pass on focusin into a text-entry target (the exact complement of its keyup withhold — a readonly field's keyups still forward, so its focus doesn't reset), and the host runs the same silent handleInputReset (RESET clears held state without synthesizing release edges, so no keyup binding fires from focus movement). Both deliberate behaviors are pinned unchanged: typing stays withheld, and world clicks survive the blur carve-out — the reset rides focusin-into-text-entry, never blur.
  • The summoned builder body spawns a conversational step OFF the spawn point — the face-inside-the-camera frame is unrepresentable (BT's Fortress Run QA, dumps 8d7d5182/260e69a8). Every joining session self-spawns at the place's ONE deterministic spawn point (lifecycle.ts configurePlayerEntity: defaultSpawnPoint → player feetPosition → implicit terrain default), and in a first-person game the player's camera sits exactly there — so every builder summon materialized Savi's body 0.0m inside the player's head, and the shell's post-hoc arrival teleport (arrival-position.ts) only corrected it ~1.3s later, once per summon, every summon (three "spawned 0.0m from player/…" placements in one QA day on one app). The helper class (the verified room_host token class — never game-declared) now spawns HELPER_SPAWN_OFFSET_M (3.25m, the arrival etiquette's conversational band) along the spawn facing and already turned around to face the spawn point: the spot the etiquette used to correct into, decided at placement so the coincident frame never exists. Slope-aware (the offset spot never lands below the terrain sample there), 3D places only (2D modes have no first-person camera and their axes carry different meaning), and the same placement rides resetPlayerEntity's helper re-mint, so game resets stop slamming the body back onto the spawn point too. The spawn is receipted (HelperSpawnPlacementResource → the new session.spawnPlacement debug answer, supported:true seam-marker idiom): the shell's arrival positioner stands down on the receipt (arrival: engine-placed on /healthz) instead of re-correcting a body the engine already placed — a post-hoc correction would anchor on the body's own offset spot and could step it right back onto the spawn point. Older pinned engines answer no marker and keep the legacy post-hoc teleport byte-for-byte. Player spawns are untouched.
  • Seat-aware authority family: replaceSpecWithStarter (and the naive mode-as-authority guards) work on the relay tier's elected host (ledger 2314 — jacob's live repro; tide's census, thread 1786845973). Savi's run_script on relay rooms executes on the ELECTED HOST client — the room's real authority since R5 (#12739): it runs the sim, and the exec arrives through the trusted room_host writer class (relay-script-exec.ts). But mutation guards tested getWorldMode(world) !== "server" — a pre-R5 rule that knows only server worlds and singleplayer clients — so the exact seat that IS the authority was refused. The primitive family (object-api.ts, beside getWorldMode): isPlaceAuthorityHere(world, placeId) on the readPlaceHosts spine (kernel server TRUE even for client-hosted places — it persists/validates; SP true; a relay-wire client from its elected seat, handoff-safe via the table's write-version memo + the make-before-break barrier), isRoomAuthorityHere(world) = the ANY-held-seat form for place-less verbs (the exec lane's own election: an unstamped request runs at the defaultPlace host when one exists, and at the sorted-first held seat when every player has walked out of the default place — room code is room-scoped, so the whole-room predicate answers the same question), and isGodModePersistAuthority hoisted beside them from the god-mode edit accumulator (semantics unchanged). Swapped in this pass, per the census's naive-safe list: the replaceSpecWithStarter guard (the throw fix — jacob's repro; the swap's live-teardown choreography is a separate follow-up), the field-bound scatter refresh (refreshFieldBoundScattersForField), the apply-path scatter heal (the delta-0 short-circuit's pending-recovery veto + its bed-set collection), first-load scatter re-admission (readmitScatterBedsForModel, per-bed seat), and updateObjectSpec's persist axis (explicit persist on the place's elected host persists through the kiln mutation lane; every existing path keeps its default). Deliberately NOT swapped (each needs its own choreography, named so nobody naive-swaps them later): reset() (relay drain + peer convergence), emit (deferred-op), player-body minting (owner-client axis), the teach rails. Kernel-tier duties, the getWorldExecutionMode family, and the SP no-netcode carve-outs keep their mode reads — those really are mode questions. Original law preserved: spectator/non-host multiplayer clients refuse with the same teach verdicts. Pinned on the relay multi-client harness (exec-lane swap on the elected host, non-host teach refusal, election handoff re-answering) and unit-side beside the scatter-host-mint precedent.
  • The shader shed's probation is backoff, never terminal — and CPU-fused frames can't burn it (ledger 2353, jace's Tab M11 no-recovery report). Two legs in the frame-budget park machine (frame-budget-report.ts). (a) A staged-recovery probe that re-offended used to re-park as "probe-reoffense" — one probation per ref per session, edit-only after — so on hardware where any single re-enabled material tips the 40ms fused line, every ref burned its one probe and the whole scripted lane converged to a reload-only shed. The terminal reason is deleted; a re-offender re-parks recovery-eligible ("frame-budget") and each re-offense doubles the stable stretch the next probe must wait for (×2…×4 capped, RungWalker.probeBackoff's proven shape; a probe that survives probation resets it). The backoff IS the flap protection — a ref the device can't afford converges to rare probes, never to a permanent park. (b) The driver-lane re-park judged the fused signal — max(cpu, gpu, interval) — so a CPU spike coincident with a probe permanently latched the material on evidence the machine's own attribution law (handleFrameBudgetEvent: parking GPU work cannot recover CPU-bound frames) classifies as non-evidence; laggy CPU-heavy combat on a Helio-G88 was exactly this shape. The re-park site now requires GPU-attributed evidence, same as the park that opened the episode; a CPU-fused over-line frame only restarts the probe's stable clock. Post-park baseline judging, relief verification, futility restore, and look-park semantics are unchanged; the repark diagnostic no longer claims "for this session".
  • The boot gate's adapter-null verdict stops latching the Browser-Update wall (ledger 2352 — 500 adapter-null rows / 15 sessions in one day on one app, current-stable Android Chrome/151 told "Update your browser" by a wall updating could never clear). runWebGpuBootGate requests the engine's core-level adapter (featureLevel: "core" — lume's floor: cube texture views, per-stage storage budgets above the compat limit); on GLES-only/compat-mode Android GPUs that request resolves null, and the null latched WEBGPU_REQUIRED_ERROR_MESSAGE → kiln's update wall. The API's presence already proves the browser is current enough, so the gate now classifies instead: a one-shot featureLevel: "compatibility" reprobe (a classifier, never a render adapter — the ledger #294 same-adapter law is untouched) splits the class into (a) compat adapter answers → a real, working GPU below lume's core floor → the new webgpu-core-unsupported condition + cant-run copy (no update or setting fixes it; if lume ever grows a compat tier, this probe is where boot re-routes instead of walling), and (b) nothing answers at any level (acceleration off, blocklisted driver — ledger 144's population) → the existing webgpu-device-unavailable condition + honest no-adapter settings copy. Adapter-request rejections join (b). WEBGPU_REQUIRED_ERROR_MESSAGE now has exactly one producer: navigator.gpu absent — the one condition a browser update actually fixes. Both new messages ride the retries-cannot-heal condition rail (kiln walls by condition and stands its boot-stall auto-retry down) and match none of the wrong-class matchers on either side; console warns keep the "WebGPU adapter request returned null" stem the fleet's DD counting rides. device-perf.ts's ledger-895 comment claimed these devices "stop at the device wall" — corrected to the truth in the same diff. Kiln's routing half lands beside this: the core-unsupported condition renders the cant-run wall (jacob's #12893 copy family), adapter-unavailable messages from pinned engines render the settings wall instead of the update wall, and a requires-WebGPU message on a page whose own navigator HAS the API routes to the settings wall too — the retrofix for rooms pinned at ≤5.2.26, which keep latching the old message for weeks.
  • Worker-side gpu-unavailable boot deaths carry terminalBootCondition: webgpu-device-unavailable (ledger 2316 — savi-filed incident 97ca7a7c, Shoguns Return on mobile). The renderer worker's own WebGPU acquisition can refuse when the page probe passed (lume device.ts adapter-null / OffscreenCanvas-context class — ledger 280's split, now common on phones), and that death routes through latchFatalRendererError → playerWordsForBootFatal, whose r-9f46c2eb copy mapping rewrites the raw diagnostic to the calm gpu-unavailable sentence. The mapping ERASED every raw string kiln's wall matchers key on (isWebGPUAdapterUnavailableError knows "webgpu adapter request returned null", never the mapped sentence), so kiln's recovery-verb passthrough rendered the reload chip for a deterministic capability refusal — reload re-earned the refusal forever. The fix carries the CLASS, not the words: latchFatalRendererError classifies the RAW diagnostic (the same GPU_UNAVAILABLE_DIAGNOSTIC the mapper uses) and latches the named WEBGPU_DEVICE_UNAVAILABLE_CONDITION beside the mapped copy — the rail kiln already reads end-to-end (isWebGPUDeviceUnavailableVerdict → guidance wall; boot-stall.ts latches the verdict and stands the auto-retry down). Sticky re-asserts keep the condition (a racing progress post used to be able to strip routing); reload-class faults (device-lost, generic worker deaths) stay conditionless on the reload lane — the ledger-206 boundary. Pinned on both sides: the producer's condition rides every re-assert, and kiln's wall routes on the condition under ANY copy, including a deliberately reworded one.

Engine v5.2.26

Released August 16, 2026

  • Terrain edits now sync reliably in always-on worlds. Players who rejoin get the terrain exactly as it was edited — edits made by scripts or by players who have since left no longer silently vanish on rejoin. And freshly edited chunks rebuild ahead of background work, so digs and builds turn solid and visible faster even while the world is busy streaming.
  • Drawn textures (lava, water, animated art made by texture scripts) no longer vanish for a minute and a half when a crowd of players joins at once. The engine now recognizes that the stall was the join rush, not your texture script, and keeps retrying quickly instead of putting the script in time-out.
  • Destroying something now cleans up everything it generated. Roads, rails, fences and other spline-built objects leave no debris: destroying the root also removes the segments, posts, and collision pieces it spawned — including ones orphaned by an old destroy. If your world already has stranded leftovers, one destroy of the old root id (or one destroyObjects sweep) clears hundreds of them; no room restart needed.
  • Every light in your world now shows its marker in god mode — including lights nested inside other objects and lights buried in walls. Markers draw through geometry and you can click them through walls to select and edit the light. No more invisible, uneditable lights.
  • Performance health readouts now tell the whole truth on small screens: when the engine holds your game at native resolution because a lower render scale wouldn't actually help, the report says so in plain words — and the auto-quality tuner stops wasting time on resolution cuts that change nothing, reaching the settings that DO help faster.
  • Always-on worlds stop losing their hosts under load. A busy world working hard no longer gets mistaken for a dead one and restarted — the multi-minute freezes that hit heavy worlds over and over are gone. And when a world genuinely IS overloaded, Savi's tools now say so plainly ("this world saturates the host's sim budget") instead of failing with mystery errors.
  • Textures that get stuck as flat placeholder colors (the pink-blocks look) now heal themselves: the engine quietly retries every few minutes until the real texture arrives, and flipping to another tab and back still kicks an immediate retry. No more refreshing the page to get your textures back after a flaky connection at load.
  • Games can take photos now! window.spawn.view.captureFrame() in your game UI hands your script a snapshot of that player's own view — build safari cameras, photo quests, postcard booths. Each player can only photograph their own screen, the shot is pure game world (no menus or chat in the frame), and nothing is saved or shared unless your script chooses to upload it.
  • Uploading a model with shape keys (morph targets — blinking faces, facial expressions) now tells you plainly what happened: one clear warning says the engine doesn't support shape keys yet and skipped them, and the rest of the model loaded fine. No more models silently arriving with frozen faces — and no more parser jargon drowning out the warnings that matter.
  • Multiplayer worlds get their animations back. Since 5.2.18, animations authored in a multiplayer world were never actually saved to the world's animation library — other players saw characters frozen in place (one world had 129 rigged characters stuck like statues). Authored animation clips now save and reach every player, and existing worlds heal on their own: the next time an animation plays, the library mints and everyone sees the motion.
  • Leftover Savi todo-marker debris cleans itself up. Worlds that collected stray marker cubes from long-gone todo notes (one world was carrying over 2,000) get swept automatically — and the sweep sticks in always-on worlds instead of the shards creeping back on your next visit.
  • Invisible-wall checks now match where walls are actually drawn. Occupancy probes against uploaded and conjured meshes (cave shells, sculpted walls) used to test a ghost box at the object's origin instead of where the geometry really sits — the drawn wall read empty while the empty ground at its feet read blocked. Cave and mesh interiors now answer occupancy correctly, so scripted barriers and "is this spot solid?" logic agree with what players actually see.
  • Looping sounds now register in always-on worlds — Savi's audio tools can see and stop them again. In multiplayer rooms, started loops never landed in the world's sound census: "what's playing?" answered nothing, stray loops couldn't be hunted down and stopped, and players who joined late had no record of the ambience already running. Loops now register for everyone in the room, late joiners included.
  • Discord links in game UI now work in every spelling. A plain HTML link (<a href="https://discord.gg/…">) now asks the player to confirm and opens Discord in a new tab, exactly like openLink(...) and window.open(...) — and a link to a non-partner site tells you why it can't open (in your logs) instead of doing nothing.
  • Steep-slope climbing, sliding, and ground-snap are now yours to tune — skate parks and parkour walls just became authorable. On any physics: { body: "character" } object: maxSlopeClimbAngle: 70 (degrees) walks up steep quarter-pipes, snapToGroundDistance: 1.2 hugs snowboard downhills (or false to launch off ramp lips), minSlopeSlideAngle makes too-steep ground slide you downhill, and characterMass sets how hard you shove crates.
  • Rejoining an edited world can't serve you stale terrain anymore. The engine now recognizes terrain edit history by its actual content, so a rejoin that merges histories (always-on worlds, long-lived sessions) rebuilds exactly the chunks that really changed — no more terrain that looks like it forgot the last session's edits until something else forces a rebuild.
  • Passengers and vehicle parts no longer drift apart for other players. Parts spawned by a vehicle (lights, turrets, seats) used to slide away from it — unboundedly, and the drift stuck — on everyone else's screen whenever a non-host player was driving. Now the driver's machine is the single source of truth for the whole vehicle, whoever drives it.
  • A graphics-driver crash can't blind Savi for an hour anymore. When the GPU device dies, her screenshots and view tools now answer plainly — "the renderer is restarting; your content didn't cause this" — instead of surfacing raw driver errors, and her capture eye reboots itself on the very next look.
  • Debug dumps stop crying wolf about audio. The engine's planned network probes and cross-site audio fetches always LOOKED like failed requests in a dump ("[error] 0B" walls) even when your audio was working perfectly — Savi would investigate healthy sound as if it were broken. Those rows now say what they are ([probe], [opaque]) so real network errors stand out — and they never crowd real failures out of the dump.
  • Savi can now SEE which objects are solid when she looks — no more guessing why players fall through. When she identifies an object or frames it in a look, the answer says whether it actually collides right now (solid: static-mesh, solid: none, …), so a wall that only looks like a wall gets caught the moment anyone checks it instead of the moment a player falls through it.
  • Textures no longer flicker or swap after heavy editing sessions — a texture-cache cleanup racing the renderer could previously freeze the whole session with a "graphics driver hiccuped" error until reload.
  • Tile maps stop going blank mid-edit. If your tile world's generator script briefly breaks while you're editing (or hasn't finished loading), painted chunks now hold their last good art instead of flashing to flat empty slabs — and the engine tells you the generator is unavailable instead of staying silent. The moment the script heals, your pending edits fold right in.
  • Timers scheduled while a world is still starting up (runInTicks, runInSeconds, runSchedule) now fire once the world is running instead of silently disappearing — staged builds queued at startup no longer stall.
  • Voxel block textures stop getting stuck as flat white/pink. Rebuilt chunks no longer forget they're waiting on a texture (the "first block pink, second correct" bug), worlds with more than 64 block materials get all their textures instead of flat tint from material 65 on, and when a texture genuinely can't apply the engine now says why instead of silently painting white.
  • Voxel worlds stop going black when their generator script breaks. Same cure as tile maps, one lane over: if a voxel world's generator script briefly fails to compile, built terrain now HOLDS its last-good look (and hand-placed voxels keep rendering) instead of whole regions silently turning to permanent black nothing — the engine says the generator is unavailable, retries, and heals the moment the script recovers.
  • Recoloring voxel terrain is instant now. Changing a voxel material's color, texture, roughness, metalness, or emissive updates the world as a palette swap — no more full-world remesh (with its multi-second hitch and physics churn) for a cosmetic edit. Structural edits (solidity, collision, block shape) still rebuild what they must.
  • Joining an always-on world with terrain edits stops paying a growing tax. Every join used to quietly grow the world's edit history and force edited terrain to rebuild even when nothing changed — long-lived edited worlds got slower to join with every visit. Syncs are now a clean no-op when nothing changed, so joins stay fast no matter how many times the world has been synced.
  • Game photos come out the way you ask. window.spawn.view.captureFrame() still hands back a JPEG by default, but now captureFrame({ type: 'image/png' }) gives crisp pixel art with no compression, { maxSize: 4096 } keeps the full resolution, and { type: 'image/webp', quality: 0.6 } makes a small share-sized shot. Rapid double-clicks take two photos instead of dropping the second.
  • Savi stops learning object paths that only half-work. Reading an object by its list position used to succeed, so she'd keep using that number as a name — and then every edit or destroy aimed at it silently did nothing. Now the read itself tells her the object's real name the first time, so she talks about your objects the same way she changes them.
  • 2d worlds stop developing permanent invisible holes — the picture always catches up to the world. A tile chunk that got lost on the way to your screen (or kept showing old art after a re-save) used to stay wrong until a reload; now the engine notices within seconds and repaints exactly the chunks that diverged. First loads that raced the terrain script no longer come up empty with no ground to stand on — the map fills in the moment the script is ready.
  • A model that failed to load because of a hiccup now says it will retry — renaming is never needed. When the asset host answers a failure with its own retry window, Savi's scene view now reads "temporarily failed — retries on its own" instead of declaring the asset dead and regenerating it under a new name.
  • Falling through the world now puts you back instead of dropping forever — and your script can choose where. Heightmap worlds get an automatic floor far below the deepest terrain: anything that falls past it comes back at the spawn point. Tune it per place with worldBounds: { bottom: -140 } (any world kind, null for no floor at all), and take over the response by exporting onOutOfBounds(event, api) from the body's behavior — return false and the respawn is yours: last checkpoint, a damage penalty, a game over. Underground builds (terrain.underground: true) keep working exactly as deep as you build them; the net sits 64 m below the lowest terrain.

technical notes

  • Catchup-phase rail.voxelEdit rows skip present-authority re-adjudication — settled terrain history applies at the joiner's fold (ledger 2268 fix 2, #12915). A journal row replayed at catchup was already admitted by every live observer when it rode, and the relay attested its sender — but the peer translator re-judged it through senderOwnsSource against the FOLDED PRESENT, where journal self-compaction has checkpointed away the prefix carrying the source's create and seat facts. A departed session's edits therefore resolved to nothing and settled terrain silently dropped at exactly the rejoin that most needed it (dig e5e315b8: 65 drop lines in 7d on one app). Catchup/repair rows now skip the re-adjudication; the skip stays visible (warnOnce rail.voxelEdit-catchup-skip when the folded view would have refused). Live-lane rows keep the authority gate byte-for-byte.
  • Player-edited chunks jump the background remesh/collider queue (ledger 2268 fix 5, #12917). Dirty marks sourced from a TerrainChunkEdits mutation (owner + halo) carry an edit-touched flag, and both terrain build systems promote a bounded number of flagged chunks per tick ahead of the background rebuild ordering (residency sweeps, epoch-reset restates, connect backlogs), after the existing safety lanes: server promotions cap at max-submits−1 so a full tick always leaves a budgeted background submit; clients ride up to 6 promoted chunks/tick as single FULL-artifact jobs (mesh AND collider in one build) above already-queued batches. The live voxel-edit fast lane is untouched; beyond the cap a sustained edit storm competes at ordinary streaming rank, so background rebuild always progresses.
  • Churn shield for the texture bake-budget park rail (ledger 2271, dig 51dda5db, #12921). The bake budget's wall-clock law (ledger 628) measured a starved worker's DESCHEDULING as script fault: at join crunch two false faults parked healthy texture scripts 30s, and the probation probe re-faulting under the same churn doubled it to 60s — the 2271 lava-over-invisible-ground 90-second window. Third shield of the family in renderer/scripted-texture.ts (worker startup never counts against the draw; the runaway watchdog re-arms per slice): a budget fault measured during host frame churn — a >250ms tick gap observed inside the attempt window, or the live clock mid-gap at settle — doesn't count toward TEXTURE_BUDGET_FAULTS_TO_PARK and doesn't double a probation delay; the ref retries on the quick 2s ladder until the host produces calm frames. Calm-measured faults keep the law exactly as written (a hot draw loop faults in every window, so ledger 628's park is intact — pinned by test). churnShielded receipt rides RetryState into getTextureResidencyDebug for incident dumps.
  • destroy(root-id) reaps an orphaned derivation tree — and orphans are honestly destroyable (ledger 2275, savi filing 79255910, #12924). Gran Premio: spline roots spawned via run_script were destroyed, but their derived children (~788 live entities — spline segments, collision, posts, rails, kerbs) survived with NO destroy path left: destroy(root) hit the dead-target branch and read spec-absence + entity-absence as "already destroyed", and the persist half's skip receipt ("no object in the spec — destroy is idempotent") taught Savi the orphans were indestructible; the only cleanup was a room restart. A live derived child is now the counter-proof of "already destroyed": destroy(dead-root-id) reaps the live derivation tree before the zero-match warn — each stranded child through the full per-id destroy machinery (relay tomb routing, provenance, nested cascade), exactly what the live cascade would have done. destroyObjects catches the orphan shape and verdicts "destroyed" instead of "not_found" (the re-sweep is honestly not_found); the clientDestroy fan-out receiver reaps other worlds' locally-lofted derivation trees too; kiln's mutations-route skip copy now names the DOCUMENT half a no-op instead of saying "destroy is idempotent". Genuinely-absent ids keep the warn and their idempotent no-op unchanged, and the orphan enumeration is gated cheap-first (marker-column scan before the id-inference walk) so the missing-target hot loop never pays a hierarchy scan per miss.
  • God-mode light markers draw x-ray, pick through walls, and mint for nested children (ledger 2264, dig 91d1634e, #12904). Three convicted mechanisms behind "lights with no icon, can't edit them" (cal's Final Abyss), fixed together because each half lies without the others: (1) markerMaterial gains depthTest: false, routing markers to lume's overlay lane — a depth-tested marker embedded in a wall plane rendered nothing from nearly every angle, and marker-only lights had zero visual presence (guides — range rings, beam edges — stay depth-tested); (2) commitTapSelection and resolveHoverTargets promote editor-visual ROOT hits above nearer world-geometry hits, in lockstep, so the x-ray icon the creator aims at receives the click even through a wall; (3) forEachSpecObject recurses into children (464 of 1040 Final Abyss lights are children — no marker ever minted), child ids mirroring the interpreter's expansion via the shared specChildEntityId so id-less children get the same child-{index} ids applySpec spawns. Depth-capped like the interpreter.
  • The quality governor judges renderScale cuts by the SERVED scale, and health names the internal-resolution floor's fold (ledger 2261, dig 9b12cc1b, #12901). The governor's walk now folds through the same SceneScaleFoldFacts the frame seam consumes (renderer supplier → foldSceneScaleWithFacts), so relief and costHint charges for renderScale cuts are measured on served values: a rung the floor nulls is passed through instead of burning a settle window, and a partial fold charges only the pixel work it actually removes (the ≤1440px density-≤1.5 canvas class — ledger 2261's tablet). Client health gains renderScaleAsked beside the served renderScale, and describeClientQuality names the fold in words: "renderScale 1 (the ladder asked ×N; the internal-resolution floor keeps small canvases legible by design — planned, not a stuck cut)".
  • Reap rails require a stalled sim, not a missing beat — the saturated-world treadmill is dismantled (ledger 2265, dig 50710955, #12907). On the 1-vCPU room host the relay beat rides the sim drain, so a world that saturates the sim budget starved the beat past the 15s window while the sim kept ADVANCING; stale-shell-reap and beatless-replace read that starvation as death, killed the working host, and re-paid a 2-3 minute hydrate per kill (103 summons in one day — the treadmill manufactured the outage it policed). advanceShellLiveness now folds worldTick into every answered probe (lastWorldTickAdvanceAtMs + an observed ms-per-tick receipt); decideShellLiveness holds the stale reap while an advance is inside SIM_SATURATED_HOLD_MS (10 min — a frozen sim stops bumping the stamp, so a corpse still reaps bounded); the beatless-replace gate reads the same facts off the ensure answer's shellLiveness rider and LEAVES an advancing sim alive past the cutoff, while a stalled/never-observed worldTick replaces exactly as before (pre-worldTick engines keep the old gate byte-for-byte).
  • Overload confesses instead of mystifying. Beat proven absent + sim advancing slower than 2× budget mints a HostOverloadFact that run_script's terminal copy quotes — "this world saturates the host's sim budget (observed ~Xms per tick vs 33ms)" instead of relay_exec_no_host_tick mysteries; retired by the next ticking proof and by age, rate-gated so an egress fault never wears saturation copy. view_live_scene's connected-but-no-frame class (RPC-ladder timeouts, no-frame-yet, WebGPU readback teardown) is now booth-eligible with a caption naming the drowning tab instead of claiming "not connected". And a 401/403 on a server-realm /magic asset warm mints a 10-minute tombstone with retryAfterMs (an ungenerated Magic CDN variant needs an authorized surface the headless shell doesn't have) instead of re-asking every spec-sync forever; browser /cdn/* 401s and /magic 404s keep today's behavior exactly.
  • Savi-placed todo markers leave the walked world (jacob's four-beat ruling, #inventors thread 1537819871167914064). Todo markers now carry placement provenance (author, stamped at creation — kiln's savi_todos.author, never inferred). Creator-placed notes render exactly as before, everywhere they did. Savi-placed todos are hidden from the walked world by default and materialize only while the creator is in god mode (the workshop view), spawning settled on the mode flip — no assemble storm. In god mode they are display-only: every _savi-todo/ entity resolves to null in resolveSelectionOwnerEntity, so god-mode controls (select, hover→drag, attach-pick, the server selection applier) pass through a spark like glass instead of grabbing it. The wire also accepts worldVisible: true per row — the creator-said-yes opt-in that renders a savi todo like a creator one (kiln-side storage for the flag is a follow-up; the engine contract is complete).
  • The texture give-up park is a bounded 5-minute re-probe ceiling, not a terminal verdict (ledger 2267, chukinice's pink blocks, #12912). 15 anonymous transport failures used to park a texture {terminal, retryAfter +1h} — and revalidateResidency deliberately skips terminal parks, so a boot-window network flap stranded healthy voxel textures on the placeholder for the whole session, page refresh the only reliable cure. But the ladder reaches the give-up branch only WITHOUT a server verdict (tombstones, dead 4xxs, quota windows all classify first), so the 15-failure fact is about this session's transport, never the asset — terminal was the wrong shape. The crossing now settles the ladder on its ceiling: non-terminal, retryAfter = 5min (the existing long-cooldown family) + up-only jitter. The tick timer re-probes every ~5min forever (12/hr/client/asset), the visibility/projection recovery sweeps cut the clock like any transport backoff (the reporter's observed tab hide/show cure, now mechanical), and source changes / the cdn-session wake still clear immediately. Server-verdict parks (cook-kill tombstones, dead 4xxs, transport-less URLs) stay terminal — the tombstone-hammering class (dig 628c4096) stays closed.
  • window.spawn.view.captureFrame() — game scripts photograph the calling player's own view (r-709895c6, #12913). Safari Islands' photo mechanic died on the authored-realm cutover: HUD JS lost spawn-canvas.toDataURL() and no realm lane handed game code the rendered frame. The primitive, not a pipeline: captureFrame resolves a JPEG Blob of the invoking player's own game-canvas frame (the same swapchain-readback core Savi's chat screenshots use — ≤1280px, encoded in the render worker); display is URL.createObjectURL, sharing is the already-allowlisted assets.uploadImage — capture→upload→use, each verb doing one thing. One capture in flight per realm; coded capture_unavailable verdicts. Own-view-only and canvas-only are structural, not policed: the responder runs on the capturing player's page, no protocol message addresses another client, and the capture reads the WebGPU swapchain — kiln chrome, the rail, and the HTML HUD cannot appear in the pixels. No pixels reach a server unless the script uploads. game-ui.md + the PHOTO_CAPTURE_UI tome example teach the compose.
  • Shape-key drop at GLB parse warns once per file, honestly (ledger 2279, #12925). The lume parser's fixed vertex slots drop morph targets by design (jure holds the support door). The drop's only trace was a per-primitive "N morph target(s) skipped" line — parser jargon that a multi-material face minted once per primitive, flooding the 3-named cap on the model-parse-warnings diagnostic; a creator uploading a blinking face learned nothing. Now ONE file-level warning rides the same ParsedGltf.warnings lane (surfaced per-asset via reportModelParseWarnings, ledger 1862): "this model carries N shape keys (morph targets) — the engine does not support them yet; they were skipped, the rest of the model loaded". Counted from the raw JSON (primitives skipped for mode/Draco still count); per-mesh count is the max across primitives per the glTF spec, the file's is the sum across meshes. No behavior change otherwise — the drop stays, it stops being silent.
  • Relay tier: authored function clips ratify again — the simulator mints, the save lane delivers (ledger 2280, #12930). Third specimen of the unported-server-organ family (after relay-reap-lane and relay-script-exec): the ratifier (ingestAuthoredClipOffers, a mode:'server' system) never got ported when the Aug-5 flip made relay the default room host, so every multiplayer app on engine ≥5.2.18 staged AuthoredClipOffers nothing in the room could ingest — assets.authoredClips stayed empty and every remote renderer held rest pose (Nine Heavens' 129 frozen NPC rigs, 4 reports). Relay needs no ratifier organ at all: kiln is the spec authority there, so the single-live-writer rule the offer leg respected dissolves. canMintAuthoredClips now answers true on relay wires (ClientRelayWireModeResource); the entity's simulator — the one machine that can sample the played closure — mints directly, and a new client-seat system (tome/authored-clip-relay-fold, self-gated relay-and-not-singleplayer) drains the same pending→fold path the kernel server runs: both spec views + TomeSpec push + one patchAssets row the mode-both drain forwards into the relay save lane; kiln folds it onto the durable head and the SPEC fan-out delivers the library to every member. The offer leg is structurally unreachable on relay; stale pre-fix offers retire through the existing client sweep the moment a re-play mints their identity. Kernel tier byte-identical (offer→ratify→sweep); non-editor simulators' mints stay session-local at the save lane's permission gate. E2e twin: relay-authored-clips.e2e.test.ts (verified red on the pre-fix gate).
  • Savi todo markers own their prefix — untracked _savi-todo/ entities reap through the ordinary delete path (ledger 2278, dig 9b07936f, #12929). The marker reconcile despawned only what its own state.entries minted THIS session, so a fossil from any other origin — a prior session's copy restated by a relay journal head (the pre-2081 statement lanes journaled client-realm sparks as durable keyed heads), a peer's world-sync answer — was invisible to it from birth and stood forever once its todo row died: ress's 2,244 shards = 132 todos × 17 cubes, one full set per marker the app ever minted; raw-exec destroys were client-local and the journal restated every shard on the next visit. THE INVARIANT: the marker system OWNS _savi-todo/ — anything under the prefix the live entries did not mint is an orphan, reaped at every reconcile through the ordinary delete path (the ObjectAPI destroy verb): on a truth relay the ghost-destroy gate's provably-unowned lane tombs the journaled head on the reap lane, so a swept fossil cannot be restated at the next fold. Deterministic ids make the sweep exact; belt-and-braces, every minted spark entity (cubes + shockwave) now carries TomeSpawnedBy rooted at the live local avatar so client-realm-scope-reap covers the family by the standing lifecycle rule too.
  • Mesh-kind occupancy stand-ins carry the local-bounds center (ledger 2282, #12931). api.overlapPoint/overlapSegment resolved convexHull/trimesh/bespokeMesh colliders to a bounds box centered at the BODY ORIGIN — but the 3D body factory attaches the raw vertices (plus the collider-desc translation) to the body untranslated, so geometry authored off origin (Savi's shared-anchor cave walls, ~30m from their own feet) probed at a ghost position: the drawn wall read empty, the empty feet read blocked. resolve2DMeshFallbackHalfExtents becomes resolveMeshFallbackLocalBounds — half-sizes plus the BODY-frame center (vertex-cloud midpoint composed with the collider-desc translation, resolved from the same sources makeColliderDescs builds colliders from). The occupancy OBB and the F3 mesh marker now sit at that center, rotated with the body (the box case's translation precedent). 2D consumers are untouched by construction: the 2D fallback CUBOID is origin-centered (makeColliderDescs / mantle parity), so the feet-offset, 2D collider-desc, and mantle paths keep reading half extents only. Rapier-truth pins verified red on pre-fix code (dig cff2411e): an offset-authored bespokeMesh answers at the DRAWN wall and stays silent at the feet-displaced ghost, both directions, with rapier ray receipts for where the vertices actually are; centered-mesh control unchanged. The approximate contract (a concave shell's box covers its interior) is documented behavior and unchanged.
  • Relay tier: the loop-voice registry gets its wire — 4th specimen of the unported-server-organ family (ledger 2280 census). On relay rooms the registry (TomeSoundLoops, music-apex D3) had NO writer and NO wire lane: recordSoundLoopStart's gate (isSoundLoopAuthorityWorld) refused every multiplayer client, the ingest sweep is mode:'server' and never runs on the tier. So audio.playing().loops answered [] in every relay room (the census the audio skill teaches Savi to sweep with), the stopSound never-minted teaching lost its registry belt, and a late joiner had no standing-loop truth at all — loop starts are juice EVENT rows and relay catchup strips event rows as history (the join-storm-juice gate in relay-peer-apply). The port is the relay-music-lane pattern verbatim (same tome/spec entity, same free-running-clock problem, same journal): the play-executing client — the room's one machine that saw the verb — records into its LOCAL registry (isSoundLoopAuthorityWorld gains the ClientRelayWireModeResource arm, the #12930 mint-gate stamp), publishRelaySoundLoopsHeadIfChanged ships the head as one anchored tome/spec keyed row on both netcode egress edges, and the ingest half rebases every entry's startTick into the receiver's clock, strips the rider, and stamps the echo guard — LWW whole-map by the room's total order (the concurrent-jukebox posture, named in the lane doc). Scope, honestly: this delivers the CENSUS and with it the primitive creator code needs to replay standing loops for joiners (the D3 posture — the sweep stays creator code); no tier auto-replays registry entries audibly, a cross-tier pre-existing gap. E2e twin relay-sound-loop-lane.test.ts (mint gate both wires, anchored egress + quiet-tick dedupe, stop head, catchup rebase = the late-join delivery, round trip through the real parse gate, anchorless strip), verified red pre-fix: reverting the mint gate alone fails exactly the four mint-dependent pins.
  • <a href> anchor clicks in authored game UI ride the openLink door-out lane — the third silently-dead external-link spelling dies (ledger 2286, savi filing ff51ce5b, Shogun's Return round two). The realm sandbox has no allow-popups and the shell owns navigation, so a target="_blank" anchor click died with no confirm, no window, and no error anywhere — and a default-target anchor would have navigated the realm document itself away, killing the game UI behind a frame-refused external page. A document-level bubble listener in the frame (realm/frame/open-link.ts) now consumes every external http(s) anchor/area click and routes it through the same openLink machinery as window.open: partner domains (exactly discord.gg) request the owning shell's confirm sheet naming the destination; anything else gets the readable refusal verdict plus the teaching line in getLogs; http refuses not_https loudly. Authored preventDefault() keeps the click (game code that owns its anchors is untouched); fragment/javascript:/unresolvable hrefs keep native in-frame behavior. Same allowlist, same confirm, same rate caps — no policy widening beyond jacob's 08-14 ruling.
  • Character-controller tuning is now authorable from object physics (savi platform-gap filing 043591b0). PhysicsCharacterControllerConfigValue always had every field; the spec merge exposed only autostep. The merge (tome/physics.ts) now resolves the whole config from the spec: maxSlopeClimbAngle / minSlopeSlideAngle (DEGREES at the spec surface, clamped 0–90, radians on the config), slideEnabled, snapToGroundDistance (meters, non-negative, false = never snap), characterMass (kg, > 0), applyImpulsesToDynamicBodies, offset, normalNudgeFactor. Defaults unchanged — 45° climb, 0.4 m snap, slide on — so every existing game builds a byte-identical controller config. Wrong-typed/non-finite values drop per field so the default stands; filterGroups stays owned by collisionGroups, up/filterFlags/gravityScale stay non-spec (probe incoherence / raw backend bitmask / dead field). Authored values echo in getProperty('physics') (degrees/meters/false, only when differing from the defaults) and re-applying the read-back reproduces the identical config; removing an authored field resets it to the default on re-apply. The controller-config defaults now live in ONE home (tome/physics.ts DEFAULT_CONTROLLER_CONFIG; interpreter + ObjectAPI consume it), and spec re-applies value-compare (f32-tolerant) before writing so an unchanged re-apply no longer dirties replication.
  • Chunk journal identity is a content hash, not the (revision, timestamp, count) triple — the rejoin stale-terrain hole (ledger 2268 fix 3's residual class). The chunk build cache key, the durable-baseline install's converged-skip, and the serialize-reuse fingerprint all identified a chunk's edit journal by appliedRevision:lastServerTimestamp:count — unique on ONE lineage, but a relay rejoin merges lineages (the container-era baseline rebases under the session journal, an answerer's world-sync statement is compacted into synthetic ids, a reorder wears the same stamps). Two journals of different content under one triple meant the cache answered the STALE inputsHash and the installed mesh passed as current — or the converging install was skipped as "already converged". terrainChunkEditsContentHash hashes the ordered editId sequence plus each command's bbox/payload, memoized per value object; all three fingerprints now include it. Pinned: same-triple journals with reordered/foreign ids/different payloads hash differently; a byte-identical re-statement stays the churn-free fixed point.
  • The ownership walks are controller-aware on chain links — transferControl now moves a whole spawned subtree's authorship, not just the target's (ledger 2299, incident 9eca5c1d). Both owner walks — the server's resolveEntityOwnerClientId (tome/ownership.ts) and its client mirror walkReplicatedOwner (tome/api/control-utils.ts) — probe TomeController at EVERY link of the TomeSpawnedBy chain instead of only on the starting entity. Controller-blind, children of a member-driven vehicle resolved unowned: the place host claimed them into its hosted remainder and published them while the driver's envelope published them too — two writers on one hierarchy, and the host's stale/projection-poisoned child locals LWW-fought the driver's live stream (unbounded parented-child drift on every observer, made durable by the host-realm spec save). Now a driven hull's spawned children resolve to the driver's anchor: exactly one publisher, and host-driven vs member-driven vehicles behave symmetrically. Untransferred chains, stand-riders, and bone children are untouched (the walks never read TomeParent); a chain link's controller outside local AOI reads conservatively as maybe-owned, like every other dangle. The dead isEntityOwnedByClient export (zero callers) is deleted rather than left as a third controller-blind copy of the walk.
  • A lost GPU device can no longer strand the headless capture rig — captures answer a structured renderer-restarting verdict (ledger 2292, savi filing bc24dd58, #12940). The Ashenreach dead hour: the capture rig's GPU device died and stayed dead ~1h — every free-camera capture surfaced the raw Dawn throw ("Failed to execute mapAsync on GPUBuffer: [Device "spawn renderer"] is lost"), viewport grabs claimed "the renderer has not produced a frame yet", and six wisps stayed blind continuously. Kernel half: after device.lost latches, the frame loop keeps ticking (WebGPU calls on a lost device are no-ops), so the capture admission gate saw a live loop and parked captures toward raw mapAsync throws. Now deviceLostPosted answers every capture ask immediately with the structured verdict (DEVICE_LOST_CAPTURE_ERROR — planned-events law: "this is a device/renderer failure, your content didn't cause it"), checked first in frameLoopPausedReason and translated at the three result funnels (scene view / object preview / pixel provenance) so even the in-flight capture racing the loss answers the verdict, never the raw driver text. The booth half (dead-eye retirement → reason 'renderer-restarting', the automatic renderer restart for headless sessions) deploys with the booth process, engine-version independent. Receipts: pre-fix red (3 failed) then 32/32 renderer-device-loss vitest green.
  • The network ring names its own planned requests — designed opacity wears [probe]/[opaque], never failure vocabulary (ledger 2290, dig 63bce214, #12943). Every dump of every audio-playing game carried a wall of fake "[error] 0B" rows (~2k/day platform-wide): magic-cdn readiness probes answer opaqueredirect (status 0 / 0 bytes BY DESIGN), and payload fetches ride a 302 to spawnfile.io whose missing Timing-Allow-Origin zeroes sizes for fully successful 200s. The ring classified both as [error]/[stall], teaching every reader — Savi included — that audio was failing while the serving path was healthy end to end. A probe is field-identical to a genuine network error in every PerformanceResourceTiming attribute (verified empirically in Chromium: all-zeros either way — opaque-redirect filtering, not the TAO check, zeroes it), so the engine now names its own planned request: ensureMagicCdnAssetReady registers the URL with the ring (noteResourceProbeUrl, bounded) before the probe fires, and the matching all-zeros row labels [probe] — regardless of duration, replacing the <2ms carve-out that missed every network-served probe on a real-RTT link. A media element loading a registered URL reads the same designed zeros and labels [opaque]; the TAO-blinded successful fetch is detectable without registration (Chromium exposes responseStatus through a failed TAO check while zeroing sizes AND responseStart, so status 1-399 + zeroed sizes + responseStart 0 labels [opaque] — a TAO-passing cache hit keeps decodedBodySize/responseStart and can't match). Both labels ride the NORMAL TAIL, never the anomaly ring — designed opacity can't evict real failures. The honest-failure lane is untouched: status>=400 and unregistered same-origin all-zeros with wall time spent still label [status]/[error], and slow outranks opaque because duration survives TAO opacity. Verified end-to-end in real Chromium against real probe/no-TAO-redirect/socket-destroy traffic.
  • identify_object and frame looks now carry the solid verdict — collision state is visible on the surfaces authors already use (ledger 2297, the Castle Oravelle forensics: 63% of a wisp-built castle carried no collider and every verification look passed the ghost walls). Each 3D identify match wears one field, solid — the entity's LIVE collider registration at query time, read off the sim's physics components (tome/solid-state.ts), never the spec's intent: <body>-<shape> in the physics spec's own vocabulary (static-mesh, static-box, dynamic-box, trigger-box, character-capsule, vehicle-box, …) or the one word none — no collider registered, players and physics pass through. view_live_scene's frame captures carry the same field for the framed object. The read rides the existing main↔sim debug rail as a new pure physics.solidState batch request (liveSpecDrift's sibling; the rail helper is now shared in _entry/client/sim-debug-rail.ts), fails OPEN (no rail / old worker ⇒ the field is absent, never an invented none), and skips terrain matches (terrain collides per-chunk under engine-local ids — a per-object none there would be a lie players stand on). No spawn default changed: omitted physics still means no collider — the census showed most omissions are correct (lights, fx, overhead vaults, banners); what was eliminable was their invisibility, not their existence.
  • The lume texture service's live destroys (eviction, retired-handle sweeps, superseded repaint sources) now defer behind submit retirement: handles queue and destroy when device.queue.onSubmittedWorkDone resolves, so a destroy can never fire between a frame task's encode and its queue.submit — the "Destroyed texture used in a submit" validation class renderer.ts latches as an unrecoverable session (dump 718f34ef, ledger 2298). Handles released while a fence is in flight join its batch. Teardown (dispose(), device-lost recovery) destroys immediately — recovery never waits on a dead queue — and fence-less stub devices keep synchronous destroys.
  • A null tilemap generator module must never silently empty a chunk (ledger 2283, savi filing — app af41454d "Ashenreach", dump group 28625c83, #12934). Any chunk touched by a runtime tile override (api.clearTile / api.setTile) repainted as a flat pale slab while the definition's generator module was transiently null (mid-edit broken script; the generator-module memo pins null per definition object): evaluateTilemapChunkCells silently evaluated overrides-only cells and the forward lane faithfully delivered the fabricated-empty chunk over standing content. The silent degrade dies: evaluateTilemapChunkCells returns null when the definition declares a generator but its module has no tileAt (compile failure, missing export, or a pinned transient) — it never fabricates empty cells for a generator-backed map. refreshDirtyChunk HOLDS on null: last-good cells and colliders stand, both cursors stay diverged so the sweep retries every tick, and the pending edit folds in the moment the module resolves or the next revision bump republishes wholesale. publishChunk degrades to overrides-only on null (a fresh publish has no last-good cells to hold; broken-from-install maps keep rendering their hand-painted setTile tiles — the documented posture). Either way the engine says so: a tilemap-generator-unavailable diagnostic, once per content version. Pure-override maps (no generator authored) evaluate exactly as before. Tests pin both directions: the constructed mid-edit null holds the chunk, says so once, and heals on restore (fails pre-fix with the exact silent-empty re-forward); the healthy same-sweep carve lane is pinned untouched; the broken-from-install degrade is pinned loud.
  • Timer dispatch now HOLDS a due timer whose owner is simulated by NO world (a cold room's place-host election window, an unhosted or successorless-held place pause) instead of consuming it unfired — it fires on this world's first simulated-here tick at or after due, mirroring the authority-pause retention semantics. New predicate isEntitySimulatedNowhere (tome/simulation-authority.ts). One dispatch site covers the whole timer family: runInTicks, runInSeconds, runSchedule, onModelReady's poll, engine-owned cleanup timers.
  • Voxel block textures stop stranding on the white placeholder — three causes in voxel-buckets, none of them the fetch ladder (ledger 2267, chukinice's World of Spawncraft, 117 materials). The pink/white-block class was fixed twice at the texture service's retry ladder, but the module that decides whether a voxel block gets a texture (lume/terrain/voxel-buckets.ts) had three defects, each sufficient alone: (1) rebuild severed texture→chunk tracking — syncTerrainVoxelChunk bound the fresh palette BEFORE removeVoxelBucketChunk deleted the chunk from chunksByTexture/chunksByPendingTexture, so every rebuilt chunk (edit remesh, forced remesh, LOD swap) referencing a still-loading texture was forgotten and wore layer 0 until an unrelated remesh — now split into removeVoxelBucketChunk (residency only, replace-in-place) and retireVoxelBucketChunk (left the scene); (2) a literal 64-layer albedo array, never grown, reservations never released — material 64+ of a 117-material world rendered flat tint forever; the array now grows by doubling (bounded by the device's granted maxTextureArrayLayers), copying resident layers on growth; (3) every mask verdict (uncompressed source, family/mip mismatch, ceiling) was silent — verdicts now land on rails dumps and Savi read.
  • A null voxel generator module must never silently empty a chunk (ledger 2293, savi filings 9985add3/3c4ee508/933ef3c5 — app b2537c36 "Blockcraft: Coelho & Cobaia"). The voxel twin of the tilemap silent-empty law (ledger 2283): when the generator module resolved without materialAt (compile failure, or a transient pinned by the generator-module memo — never authored state; spec-apply refuses voxel definitions without a compiled materialAt), buildVoxelChunkData returned null and materializeVoxelChunkOutput minted a VALID EMPTY output — geometry renderDataState "omitted", colliders [], a real outputsHash. The job succeeded, tracking marked the chunk clean forever, later edits rebuilt through the same null and stayed empty, and nothing said so (the ledger-1771 failure-visibility lane only sees throws) — a contiguous canyon region rendered as a pure-black never-rendered mass on every client while host sim stayed healthy. The silent degrade dies, on the tilemap template: the build emits the OVERRIDE-ONLY degrade (an all-air grid with the chunk's voxel edits applied — hand-placed placeVoxel content keeps rendering) tagged generatorUnavailable, plus the anomaly-law voxel-generator-unavailable diagnostic once per content version. Consumers with a standing output HOLD: last-good mesh/colliders stand, the chunk routes into the ledger-1771/#597 failure lane (visible warns, doubling backoff, park on proven-deterministic with the park lifting on any inputs change), and heals the moment a build completes with the module resolved. Never-built chunks install the degrade — the broken-from-install posture — and later degrades keep installing so edits keep folding in while the generator is down. The generator-module memo's null pin stays: the hold makes it non-destructive. Tests pin both directions with pre-fix receipts; the healthy lane is pinned unchanged.
  • Voxel material shading edits (color/texture/roughness/metalness/emissive) no longer rebuild every chunk — the voxel edition of the heightmap shading fast path (ledger 1324's split, voxel side). Palette-only fields were folded into the terrain signature, the definition revision, AND every chunk's definitionContentHash — so any patchTerrain touching a color or roughness re-keyed every resident chunk (~1500 on a desktop voxel world), marked the whole place dirty, dropped every fast-lane resident grid, and paid a full remesh + collider swap across the world. The split mirrors the heightmap classification: STRUCTURAL (id, opacity, transparent, liquidKind, surfaceInset, solid, cutout, collision, countsAsTerrainSurface, the block script + baked shapeTable — everything the mesher reads) keeps re-keying builds; SHADING-ONLY (color, texture, roughness, metalness, emissive, emissiveIntensity) rides the per-place TerrainPlaceVoxelMaterials library to buildVoxelMaterialOverrides with no chunk rebuild.
  • The world-sync terrain statement ships journals verbatim — compaction there made every sync grow every edited chunk's journal and tear down its resident grid. The answerer compacted each chunk journal before stating it, but compaction mints synthetic terrain_compact_* ids that match NO live row, and the requester converges by editId — so every statement was a non-append: the journal grew one compact stamp per sync (probe receipt: 20 → 21 → 22 → 23 → 24 across four syncs), and the fast lane tore down every edited chunk's resident grid on every join/rebase. Compaction keeps earning its keep at the durable save/load seams (ledger 790) where its ids never meet a live journal; on the relay statement the payload is byte-budgeted per frame anyway and a verbatim statement over an identical journal is a no-op install. Pinned: a joiner holding the host's 20 edits sees three successive statements leave its journal byte-identical (red on the old compacted statement).
  • view.captureFrame({ type?, quality?, maxSize? }) — the encode is the game's, and calls queue instead of dropping (chucky, incident 4ebda0e1 follow-up to #12913). The primitive shipped with its policy baked in (JPEG q0.92, ≤1280px — the chat-screenshot defaults) and one-in-flight semantics that dropped a rapid second press with capture_in_flight. toDataURL(type, quality) gave the Shutter Isles HUD both knobs; the realm verb now does too. The encode contract has one home (renderer/screenshot.ts resolveScreenshotEncode(w, h, options) — the same function every viewport capture rides: chat screenshots, SEO/savi-note thumbnails, scene views): type ∈ image/jpeg | image/png | image/webp, quality 0..1 (lossy codecs), maxSize = longest-side fit in px, never upscaled — the swapchain frame is the structural ceiling, no separate constant. Defaults are byte-identical to before. The responder validates the ask at the realm boundary (parseScreenshotOptions): a typo ('jpg', quality: 92, maxSize: 0) answers a bad_args verdict, never a throw across the port; the validated options travel realm → responder → renderer handle → capture-screenshot worker message (screenshotOptions) → the readback+encode. Captures now SERIALIZE per realm — each call awaits the one before it and takes its own frame, in order (a double-click is two photos), bounded by the sdk.call rate cap and the 15s host screenshot timeout; the chain does not hold the small asset lane, so a photo burst never starves audio/texture requests. capture_in_flight is gone from the code, the skill, and the tome example; capture_unavailable (no frame yet) and too_large (the 32MB relay cap — a structural backstop, unreachable for any real frame) remain. Renderer-side, pending screenshot requests carry their options and requests sharing an encode still share one readback+encode of the same frame.
  • getSpec refuses list positions on id-keyed arrays — the read lane now answers the way the write lane treats the same handle (ledger 2307, ravi's teach-savi ruling; the ledger-1247 masquerade). The read lane used to RESOLVE places.main.objects.1922 (a numeric array position) with a warning while destroy/setObjectProperty refuse it — an inconsistent primitive that taught the wrong grammar: Savi learned the shape from read receipts and died at the writes (app 3ad02cb1 lost a session to destroy("1922") no-ops minted by exactly this read). At the shared resolver (readSpecPath, the one spec-path door — god-mode/inspector reads go through id-only findSpecObject), a numeric segment landing on an id-carrying array element now refuses: getSpec answers null — the same handle those verbs would ignore — with a teach verdict naming the durable id ("1922" is a list position, not an id; the object at places.main.objects[1922] is id "body-canary-hands" — use getSpec("places.main.objects.body-canary-hands")), digit-folded to one teaching per path shape per cooldown. Ids win over positions: an id that merely LOOKS numeric (an object literally named "1922") still resolves and is never shadowed by whatever sits at that index. Arrays without id-keyed elements (tags, spline points, …) have no id grammar to protect and stay positionally readable, unchanged.
  • Tilemap chunk residency reconciles sim↔renderer — the silent-divergence class dies (ledgers 2303/2300). The sim→renderer tilemap chunk forward (terrain/tilemap-chunk SAB lane) had no delivery guarantee and no reconciliation: render-channel stream-reset recovery discards buffered frames and a poisoned reader drops queued ones undecoded, and forwarded cells are not ECS components, so the recovery snapshot never re-derived them — a lost chunk frame left the renderer's resident set diverged forever (2303's never-painted rows, 2300's stale-after-resave art). Now every forward carries a publish revision (codec v6), the renderer reports its resident chunk keys + revisions per place on the 1Hz perf-sample relay (collectTilemapResidencyCensus, cached per residency revision), and the sim's reconciliation sweep (reconcileTilemapResidencyForPlace, run from render/ecs-sync's tilemap drain) diffs the census against the retained forwards — re-sending exactly the missing/stale chunks, sending cells: null removals for ghosts, and pacing itself behind a quiet window so in-flight publishes are never judged as divergence.
  • The boot-window generator race repaints in-session — both terrain lanes (the 5.2.26-candidate zoo side2d first-load specimen). A first load that sweeps before the generator module resolves used to install the override-only degrade and mark it clean forever: tilemap publishChunk had no retry at all (fingerprints move on override churn, the window on viewer movement, the revision on def edits — none on module resolution), and the voxel lane's never-built generatorUnavailable install (ledger 2293's broken-from-install posture) reset the failure entry and recorded the degrade as the inputs' installed hash. Now tilemap tracking remembers degraded publishes and republishes them — cells, forward, and colliders — the sweep after tileAt resolves; voxel degrade installs enter the failure lane (backoff retry, park on proven-deterministic) and deliberately stay out of installedHashes, so the dirty mark survives the spurious-mark absorb and the healed rebuild passes the install verdict on both realms.
  • Client debug dumps carry a tilemap census. EngineBugRenderDiagnostics.tilemaps mirrors the same census rows (place, chunkCount, space-joined cx,cy@revision — clipped at 4KB per place on ingest, chunkCount stays complete; [] is a positive no-tilemaps receipt, null = pre-instrument). Tilemap state in dumps was previously ZERO (terrainTextures is heightmap-only) — 2303's "rows r ≥ 0 never paint" took a multi-session dig instead of one grep over this field.
  • The failed-asset teaching splits by the server's own Retry-After (ledger 2306, incident 14e46bc9). The scene-view unready report's "failed" bucket taught one story — "a receipted death … stays a placeholder until regenerated under a new name" — for every failed verdict, including the TTL'd tombstone kiln mints for TRANSIENT trouble (the import/serve lane answers a source-fetch 5xx as 502 + x-cook-state: failed + Retry-After = remaining TTL). Five known-good uploaded GLBs earned that permanent-death teaching off a ~6-minute workshop-door restart while the files served 200/valid glTF 40 minutes later — reference churn for alive assets. Now a failed verdict that carried the server's own Retry-After window rides the unready atom as retryInMs (RendererAssetService marks serverRetryWindow on the terminal-park and ladder writes; the lume texture service's cook-kill park mirrors it), and buildSceneViewAssetNote renders it as its own clause: "TEMPORARILY failed to generate … the engine re-asks on its own within ~Ns … do NOT regenerate or rename". True terminal verdicts — dead 4xx answers, content-policy rejections, no-window parks — keep the receipted-death/rejected teachings, and the terminal clause still leads the temporary one. The wording deliberately avoids "still generating/loading", so capture settle gates read a timed pause as settled trouble, not streaming.
  • worldBounds.bottom + onOutOfBounds — the bottom world bound as scriptable vocabulary (r-1a763818, the second half of DJ Def1le's ask). terrain.underground: true (#12802) removed the surface fall-catcher for declared under-terrain builds, leaving below-surface falls bottomless. The new place field is the deep net: a character or vehicle whose feet cross below the resolved bound (authored worldBounds.bottom anywhere; 3D heightmap places default to verticalRange[0] − 64) fires the body's own onOutOfBounds(event, api) hook once per excursion — event = { bottom, position, placeId, respawnPoint } — and, unless a hook returns false to own the response, the engine teleports the body back to the place's spawn point with motion zeroed (physics-coherent feet+body teleport, the chunk-rescue's transform discipline). The engine owns detection and the default; the response is the creator's script — respawn-at-checkpoint, damage, end-the-run are all one hook, never engine surface. Detection, hook, and default all run on the body's simulator (shouldSimulateEntityHere, the behavior execution-authority rule), every catch speaks in the runtime log ring (world-out-of-bounds), and the latch re-arms only when the body is next observed above the bound — no repeat-fire loop while below. The chunk-rescue is untouched: where it is armed it catches sunk bodies at the surface reading long before any bottom bound.

Engine v5.2.25

Released August 14, 2026

  • Big worlds with lots of detailed scenery run noticeably smoother while you walk around — the engine now remembers collision shapes it has already built instead of rebuilding them from scratch every time objects stream in or toggle their physics.
  • Behind-the-scenes: when something goes wrong in a game, the team can now see exactly which engine version it happened on — fixes land faster.
  • Fixed a bug where things your scripts built could come back invisible after a server hiccup — and stay invisible no matter what. The world now heals them on its own, and re-running the script that built them fixes them too.
  • Savi now hears loudly when a script's changes applied live but nothing reached the saved game — instead of discovering it after a reload wipes the work.

technical notes

  • Content-keyed collider shape cache — the trimesh BVH construction churn is gone (dig df45b868). 81% of rapier's wasm time in a dense-place walk was Bvh::rebuild_range_binned under createCollider (160–185ms/s sustained): spec-level physics none↔static toggling and AOI streaming re-create mesh colliders continuously, and rapier.js rebuilds the full trimesh BVH inside every createCollider call (desc.shape.intoRaw()), then throws it away. The new cache (engine/physics/rapier/shape-cache.ts) keys BUILT wasm shapes by the engine's stable content identity — bespoke/cook/terrain signature + exact realized scale + trimesh/hull lane — and shares one wasm SharedShape across every collider realized from the same geometry (an Arc refcount bump per collider, standard native-rapier shared-shape usage recovered through the JS glue). Colliders stay fully independent (pose/friction/groups/sensor live on the collider); eviction is LRU under byte+entry caps and restores stock rebuild-per-use behavior on the shared JS shape, so a freed entry can never invalidate a standing collider. Bench on the shipped package: 2,048-tri trimesh createCollider 1.21ms → 0.12ms (10.3×), 8,192-tri 3.97ms → 0.43ms (9.3×); the dig-shaped sustained churn frame drops 4.3×.
  • Every kernel log row now carries the booted engine identity (engineVersion + engineHash) — the DD engine-version facet, kernel/container half. The container-tier env seed (SPAWN_ENGINE_SEMVER/SPAWN_ENGINE_HASH → logger base context, ledger #421/#584) already existed; this closes the realms it never reached: the workerd engine isolate (a fresh realm with empty process.env) now seeds the pair from CONFIG.engine at EngineHostCore construction, RoomShell stamps its engine identity (x-spawn-engine-* headers, durable) onto every shell record instead of only the failure-line identity block, and the container host wires shell records through the kernel logger — which also stops hookServerConsole re-wrapping warn/error shell records into lines whose message is the first line's raw JSON. Logging only; no engine behavior change.
  • Journal-replay husks are no longer permanent (ledger 2241). A runtime-spawned entity restored from the relay journal materializes sim-complete but drawable-free (Draw* components derive only where the spawn path executes, then replicate) and stays invisible until its manager's onSpawn re-mints it. Two seams made a sick host lane (2239 residency family) freeze that state forever, and both are closed: (1) the deferred-onSpawn drain (tome/lifecycle.ts) DISCARDED a parked hook when its entity read remotely-simulated — but a zombie host row (named in the table, not proving) reads exactly like a live one, so the drain threw away the very re-mint it exists to deliver; the entry now WAITS and fires when authority lands on this seat (re-election, heal, migration). Deterministic-id spawns adopt live children in place and heal missing visuals, so a re-run after a genuine remote run converges instead of duplicating. (2) api.spawn()'s adopt-under-reclaim path returned with no writes for an unchanged child, so a script re-run — the one lever a creator reaches for — was a no-op on the very husks it should have healed; adopt-under-reclaim now heals visuals like any adopt, husk-gated (the spawn call declares a visual, the entity wears none) so live drift on drawing children stays untouched.
  • The nothing-persisted verdict — a persist:true run_script whose durable batch comes back empty now says so instead of silence (ledger 2244, dig 2ad10ad8). The chain had no honest failure path for persist-requested + world-mutated + empty-durable-batch: kiln's exec route skips its persist leg on an empty batch, studio-chat's fallback skips on empty mutations, and instance-place partition drops never reached droppedMutations — a relay-hosted exec once applied 18 live writes, persisted nothing, and told nobody (the creator burned a full session filing the "revert"). Three legs, one contract: (1) the exec engine's instance-place partition now reports through droppedMutations with a reason field ("instance-place" — the write landed on a session TomePlaceInstance row that dies on room recycle; "persistence-filter" — the existing dig-9b58cad7 class), wired kernel→wire→kiln→chat; (2) kiln's exec route stamps persistOutcome: "nothing-persisted" on persist + kernel-proved worldMutated + zero durable mutations; (3) run_script composes the same verdict from the underlying facts (old-kiln compat) into a structured field plus a note Savi cannot read as success, with per-reason copy naming where the writes actually live and what the durable fix is.
  • The behind-truth verdict field — a catchup-starved client now says so where Savi's instruments read (dig 439a5e6f, ledger 2242 fix 3). A relay client that knows it is behind the room's journal head (its welcome named checkpoint seq + head; its drain tracks the applied cursor) now stamps that one fact on both diagnostics read surfaces: the engine-bug client dump (behind block) and the authored-UI read receipts (look target:ui → receipts.behind, riding beside hud). The field carries behindSeqs (a stated LOWER bound — the head kept moving), behindSinceMs (the welcome instant; the join-time spec fetch delivered everything saved before it), behindForMs, and one honest sentence: edits saved after <t> have not arrived here — a delivery backlog, not a script or UI failure. Presentation floor so healthy joins stay silent: gap > 0 AND (≥100k seqs — ~50× the relay's own 2,048-row journal bound, ~86× under the 8.66M field specimen, so a fossil room reports on the FIRST look — or ≥30s of drain — a healthy join drains in seconds). Pull-only provider (runtime worker → wire session): costs nothing until an instrument reads. The field the fossil-room incident burned a surgical three-save Savi investigation for the lack of.
  • World reset re-mints behavior module instances — update-wired ears hear again after reset (r-7b0ec903). An explicit world reset drops every subscription on the rationale "producers run fresh", but a producer behind a module-scope once-guard (var wired = {} + api.on in update()) was exactly the one that did NOT run fresh: its module instance survived the content-keyed behaviorCache across reset, the latch stayed set, and the ear never re-armed — update-wired games went deaf after reset (red-pinned by the #12599 adversarial review, pins in #12602). Design call: reset = FRESH BOOT — module scope resets with the world. resetTomeWorld now sweeps the instance layer only (remintBehaviorModuleInstances): behaviorCache entries holding a compiled instance for the world's scripts are dropped, so the reset apply re-executes each factory into a fresh module. Every compile artifact stays warm by design — module artifacts, the function-factory memo (re-mint is factory re-execution, zero codegen, sealed workerd isolates untouched), source/dependency caches, and cached compile failures (no instance ⇒ no module state). The it.fails pin flipped green.
  • lume particle slab-bank refusals speak, and the FIFO stops starving later effects (#12401, the mechablade whole-effect vanish). A slab-bank allocation refusal now emits one console line + one fx-arena-refused diagnostics event per deferred stretch (placement re-arms; announce-once lifecycle), instead of silently dropping the effect. The pending-FIFO skip-scans: the head keeps first claim on its slab class but no longer blocks later effects that could place now — early greedy effects can no longer starve whole later effects out of existence. Under-pressure eviction ("make room") is deliberately out of scope — design fork in the PR body for jure's ruling.
  • worldPosition / worldFeetPosition — the world-frame position vocabulary (r-99ba97f9, BT dig fcdbe249 §2). feetPosition on a parented child is the documented LOCAL seat/part offset, so a script that computed a WORLD point and wrote it onto a parented rider was silently reinterpreted as local — BT's driver sat 17.5m from his own car in car-local space and the "invisible car" was one write. Both frames arrive as a bare {x,y,z}, so the bare write can't discriminate intent and flipping its meaning would break every authored assembly; the convert gets its own word instead: worldPosition means the WORLD point on every entity, parented or not. Writes convert world→local under the parent's current transform at write time (pivot fold and socket/part anchors included) and re-dispatch as a plain feetPosition write — mutation stream, spec mirror, and persistence only ever see the canonical local value. Reads compose parent ⊗ local on the fly. The two frames the conversion cannot honestly serve refuse with both options named (bone attachments — only the renderer resolves the animated bone frame; camera-attached subtrees — per-viewer pose), and a patch that re-parents and writes worldPosition in one bag refuses rather than guessing which parent was meant. Purely additive — no existing write changes meaning, so no engine-version gate.
  • api.raycast verdicts carry a terrain lane (BT dig fcdbe249 §3, sibling). { verdict: true } results gain verdict.terrain: boolean | null — the data-model answer the overlap probes already speak (does solid terrain intersect the ray's segment per the terrain samplers; null = unresident voxel data, "cannot know"). A null hit over real ground now says so: hit: null + terrain: true is "the ground exists and this cast could not see it" (terrain collider not resident in this runtime, or a blind context) — the fork between "collider absent" and "probe unusable" that cost Savi's repair session real time, made structural. Verdict-lane only; plain calls pay nothing.
  • Reset converges every realm to the document (r-16ed9c68, #12819). A plain reset's bump previously merge-applied an identical doc on non-authority clients — an empty diff — so client-side presentation ghosts (journal restates, orphaned children) stood through every reset. Reset bumps now ride replace: true, and on that transition non-authority realms sweep membership-carrying world content the incoming doc does not author (world-content-sweep.ts). Doc-authored entities are preserved by the replace apply's snapshot-restore branch. After a reset, the world IS the document.
  • Delete propagation: destroyed spec rows leave the doc. destroy() of a spec-declared id with no live entity early-returned without touching the document — the row survived every reset and boot, silently. Under a persistence context the row and its parent-linked subtree now leave the doc exactly like the live path. Bonus root: buildNextSpecWithRemovedObject missed the placeId:objectId qualified runtime form, so a qualified-form destroy edited nothing — fixed, live path included.
  • api.destroyObjects(ids[] | queryFilter) — bulk destroy with honest per-id verdicts (destroyed | not_found | refused+reason; live players refuse rather than filter silently). Each id runs the full single-destroy machinery — no parallel delete path. Kills the graveyard-purge chore class: hundreds of orphans is one call, and a no-op is a spoken verdict instead of silence.

Engine v5.2.24

Released August 14, 2026

  • 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 new getPlayerPlace(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 takes intensity (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 via patchState/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-*.js images) 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: true on your terrain (ask Savi, or api.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). patchObjectState on 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 existing rail.intent lane — 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, riding tome/spec beside TomePlaceHosts: globally AOI-visible, reset-proof, clientAuthWrite: "never"): clientId → { playerId, placeId } for every session avatar the server world holds, published by the new netcode/room-roster-publish server 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, null when it is nowhere in the room. This is the honest branch point the old getObjectState === null conflation 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 guarded rail.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 via PipelineCache.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 asks LookPass.presentReady() (the sync twin of the capture rig's prepare(); 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() gains intensity (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 the flash juice event; invalid input no-ops to default per verb etiquette; >1 clamps). The primitive previously had no amplitude knob at all while screenFlash did — 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 that screenFlash never 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 ready or 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.ts refreshTerrainScatter / spawnObjectFromDef / updateObject scatterChanged + field-refresh arms) gated getWorldMode(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 now shouldMintSpecScatter: 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 the no-hosted-place exclusion (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 runs redriveHostGainedScatter: ADOPT-FIRST — beds whose children are already recorded (the bed's replicated TomeState.scatterChildren; TomeScatter is 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 SuppressMutationRecordingResource wrap 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) and relay-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-map SHAPED_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/quantizeTrackedStateWriteValue are now sanitizeStateValue/sanitizeTrackedStateWriteValue (JSON-safety walk only — nonfinite→null+teach, Map/Set/Date conversion, function/symbol drops all unchanged); quantizeSimplePatch is copySimplePatch (detach-copy, no narrowing).
  • The room wire's shaped-object-map lane classifies numbers as SHAPED_OBJECT_FIELD_F64 and vec3 records as SHAPED_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 (quantizePositionLike et 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.ts classify + legacy-kind vocabulary, client-auth-intents.test.ts epoch-scale RMW inc.
  • The game-UI img lane's loading placeholder is now actually transparent (ledger 2202, dump cd8cc346, Kingsplow). TEXTURE_SCRIPT_PLACEHOLDER_SRC was 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 styled width: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, so TerrainChunkRescueSystem has 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.

Engine v5.2.23

Released August 12, 2026

  • Savi now files the features you keep wishing for with the Spawn team — a dream that comes back session after session gets written down once, in your words, instead of staying her private workaround.
  • If a script's event listeners ever get marked dead (after an error storm), registering a new listener now just works — the old "dead ears" verdict can't silence fresh code anymore.
  • Fixed the bug where every world could render as a blank white screen (the August 8 incident) — the change that caused it never shipped to live games and is now fully removed.
  • "I reported this to the team" is now true in every kind of room — bug reports from multiplayer relay rooms reach the team the same way they always have from other rooms.
  • When you summon Savi's builder body into a multiplayer room, she now arrives a polite conversational step away, facing you — never inside your face.
  • While a custom sky image is still generating or loading, the sky shows a pleasant placeholder color instead of going pitch black.
  • When a script sets a material property the engine doesn't understand (like three.js's map), the engine now says so once and suggests the right name — instead of silently ignoring it.
  • Scripts that import each other in a circle now get told exactly which files form the circle, instead of a generic compile error.
  • When Savi deletes objects that a behavior script spawns (minions, lamps, pickups), she now learns on the spot that the script will bring them back on the next room restart — so she edits the spawner script instead of leaving you a delete that silently undoes itself overnight.
  • Entering god mode (or hopping into a vehicle) while holding a movement key no longer leaves your character auto-running in that direction. Your game's own input handler gets one final "all keys released" frame when control moves away, so held-key state settles exactly like a real key release.
  • Shrinking an object with the built-in scale grip no longer flips into growing when you drag past its minimum — it holds at the minimum size until you drag back out. Growing feels exactly the same as before.
  • Going back to a place you'd left — lobby after a match, hub after a dungeon — now brings its scripts back listening. Menu buttons, referees, and anything else wired with api.on respond after the round trip instead of going silent until a page reload.
  • Swapping which script an object runs while nobody is in its place now takes full effect on the next visit — the old script's event handlers no longer linger on the object.
  • Objects parented to scaled objects (like pickup items on a scaled pile) no longer randomly grow — or jump position — when players move between places or the room re-syncs. They keep the exact size and offset you authored, every time.
  • You can freeze a character at an exact frame of an animation: mixer: { pose: { clip: 'Sit', speed: 0, time: 0.4 } } holds the 0.4-second pose instead of snapping to the clip's first frame — posed statues, card art, seated NPCs that sit HOW you posed them. Change time to re-pose; use it with normal speed to start a clip partway through.
  • Things you delete now STAY deleted after restarts. In multiplayer rooms, deleting an object from a script (or having Savi delete it) used to quietly un-happen the next time the room came back — the room's memory resurrected the ghost. Deletes are recorded for real now, no matter who or what does the deleting.
  • Editing your game from a phone in a multiplayer room now actually saves — before, those edits could silently vanish on reload.
  • The engine's helpful heads-ups (notifyDm messages and built-in warnings) now reach Savi from multiplayer rooms too — and if one fails to send, it says so in the logs instead of pretending it went through.

technical notes

  • skills/debugging.md: recurring cross-session wishes are platform gaps — file once, tell them (ferhat dig 1f4b0ce8). One sentence added to the reportPlatformGap paragraph; no tool or API changes.
  • A fresh api.on registration refutes a standing dead-ear verdict (#12511, train item 15). clearBehaviorLivenessFaults (behavior-faults.ts) clears kind:"liveness" records — and only those — at api.on registration time, for both scopes that would silence the new ear (the registration's own script scope and the whole-entity scope). A live registration is direct evidence against "these ears are dead"; if OTHER ears on the entity are still stale, the next emit re-derives their verdict through the dispatch fault floor — nothing is lost, only re-proven. Pinned by script-edit-event-rebind tests.
  • REVERT of #12506 (sev fix-forward #12519, postmortem docs/postmortems/2026-08-08-white-worlds-sev.md). The 5.2.22 draft's <meta name="color-scheme" content="dark"> + html { color-scheme: dark } on the game documents made Chromium composite the WebGPU canvas opaque white (centerMeanLuma 251.5 → 149.1 on the strip arm; either declaration alone broke it; documentElement.style.colorScheme toggled it live). Both declarations are stripped from cf-edge's iframe HTML and the cf-kernel dev shell, and both documents carry inverse guard pins: the game document must not declare any color-scheme. Engine, relay (#12502), storage (#12500), kiln, and chat were exonerated with receipts.
  • reportEngineBug gets its relay lane (#12526, r-73bad3a1, ravi's dig 0c58bbb6 — tseele's Wickworks bug died as a memory note). New dispatch lane "relay-rpc": on a relay-wired client world the incident ships over the relay's identity-stamped RPC lanes (lane "incident") to kiln's intake. The send door mounts beside the GameDB io (netcode consumers seam, capability-not-wire-mode); RPC_RESULT receipts log verbatim, so a lost filing is loud, never silent (ledger 2089's law). Platform halves riding worker deploys, not this tarball: cf-relay forwards lane "incident" → kiln /api/relay/incident (relay service auth; room scope + reporter identity from the relay stamps, never the payload; per-variant rate floor), and the store leg is shared byte-for-byte with the kernel-tier SDK-keyed door so the sweep and incident-ack lane consume both room classes generically. Head diagnostics gain dumpRail: 'relay-rpc' for provenance. Deploy order honored: relay + kiln before kernels that dispatch the lane — an old relay answers 400 "unknown rpc lane", logged verbatim as a failed filing.
  • Savi body arrival position — a conversational offset, never the joining player's face (r-2dc06081 position half; annias' partner report via mavi). Every joiner self-spawns at the place's one deterministic spawn point with zero separation machinery, so a summoned body could materialize inside a player and a loitering body filled the next joiner's camera. The fix stays in the shell (arrival-position.ts, ArrivalAnnouncer idiom — decided once per process at the first poll that sees the body, retries ride the 2s poll): live foreign player within 3m → step ~3.25m in FRONT of their replicated facing and turn to face them; nobody close → step ~3.25m off the spawn along the body's own facing and turn around; candidates inside ANY player's personal space rotate around the anchor in 45° steps. Data plane is session.teleportSelf + optional yawRad (answered yawApplied: true); /healthz reports arrival: {state, detail}.
  • Loading equirect shows the fallback base color, never a black frame (#12508, train item 21 — the placeholder ladder's first rung for pending texture bakes). sky-store.ts: a pending equirect renders EQUIRECT_FALLBACK_COLOR instead of routing to SKY_KIND_NONE (whose store default [0,0,0] cleared the frame black); texture adoption on arrival is unchanged and pinned.
  • Warn-once on unknown three-vocab material props (#12513, ledger 2107). Every Mesh*NodeMaterial constructor returns a thin Proxy whose set trap warns once per prop name process-wide (curated map for three's texture-map props, close-name suggestion for typos, compact supported surface otherwise), then performs the assignment unchanged. Known props forward with no bookkeeping; only set is trapped, reads stay raw. The teaching rides the existing shader-vocabulary-unknown-symbol diagnostic rail (#1153's channel).
  • Circular-import failures get a structured verdict (#12513, ledger 2111). script-modules.ts mints CircularDependencyError (code circular_dependency + the cycle's closed file list) at all four cycle-detect sites; the exec engine stamps the code on the outcome, the host threads it onto ScriptExecResult, and it rides the existing code channel end to end (kiln exec route and run_script already forward it). The SP-forward reply leg passes compile-class verdicts through while still dropping forge-able forward-lane codes; clone-fallback preserves it on degraded replies.
  • run_script results carry spawn provenance for destroyed behavior-spawned entities (the seele resurrected-lamps class, dig b52f2842; engine resolver 35103eafd8, ravi r-ea36708d). ObjectAPI.destroy() on exec overlay worlds resolves the target's TomeSpawnedBy provenance AT DESTROY TIME (resolveSpawnProvenance, tome/ownership.ts — by settle the component is gone), deduped per spawning owner (a batch destroy of one spawner's brood is one row: {ownerEntityId, behaviorScripts, destroyedIds (sample), destroyedCount}), ungated by persist (a transient destroy misleads identically), and rides the TransactionLog to settle as ScriptExecResult.destroyedSpawnProvenance. Live-world behavior destroys (the projectile hot path) record nothing and pay ~nothing (WeakMap keyed on the overlay, same idiom as the property-write touches). cf-studio-chat composes the fact into run_script's note: live owner with scripts names the spawner and says the durable fix edits it; a dead owner gets the softer shape (owner id kept — the child's own component asserts it — scripts unknown); no provenance leaves the result untouched.
  • Release-on-control-transfer (ledger 2129, ronnie/enigmagames stuck-WASD). tomeInputApplier tracks the last resolved dispatch target per controller (TomeControlDispatchTargetsResource); when the resolved target changes, it dispatches one final zeroed InputState (empty targets behind the memoized axis/action proxy handlers — declared axes read 0, declared actions read false) to the PREVIOUS target's onInput before the frame routes to the new target. Mechanism this deletes: after setControlMapping(player → god rig) every subsequent frame — including the keyup's zeros — dispatched at the god rig, so the parked body's onInput never ran again and game-authored intent state latched at the last held direction integrated for the whole god session. The keyup was never lost (all raw-capture flushes exist; god entry even calls clearInputState) — the latch was game state only onInput can settle, so the fix delivers a release the game's own code interprets, never an engine write into authored state keys. Release mirrors the primary dispatch's per-side guards exactly (behavior resolvable, not faulted, !isClientAuthClientSimulatedEntity) so whichever side runs an entity's onInput delivers its release; template is the parked-body action fan-out in the same file.
  • enterGodMode velocity hand-zero deleted (commands/god-mode.ts). The old special case zeroed state.velocity at entry — it missed every intent-latching game and its zero was overwritten next tick by intent-driven acceleration. Subsumption is pinned by test: a velocity-latching player script settles to zero through the release frame.
  • Size-star shrink is monotone through the screen centre (ronnie/enigmagames scale flip, dump a9c143a1). The three scale gestures in default-editors.ts (fallback hover star, attachUniformSize, emitterSizeHandle) measured the drag as screenRadius(now) − screenRadius(press) — an UNSIGNED angular distance off the pivot's on-screen point. That measure bottoms out at zero as the cursor crosses the object's screen centre and rises again on the far side, so a long shrink drag visibly flipped into a grow mid-gesture ("shrinking past a certain point flips and starts growing"). The scale VALUE never went negative (the ×0.02 factor floor predates this) — the through-zero was in the drag measure. All three now press-freeze a radial axis via radiusDragSnapshot and read radiusDragSignedFromSnap — the same monotone signed-radial primitive the radius rings and NPC vision-range grips already use (radialDragPress/radialDragSigned in builtin-authoring.ts, whose own doc names this exact failure: "dragging toward the centre keeps shrinking, never flips back to grow"). Shared factor math lives in one helper (scaleDragFactor), floored at SCALE_FACTOR_FLOOR = 0.02 — the gesture can never zero an object or mirror it through a sign flip, and a drag on an already-mirrored (negative-scale) object preserves the sign because the factor multiplies.
  • Boundary, stated on purpose: only the interactive tool clamps. Authored scale through the ObjectAPI stays unclamped — scripts legitimately mirror with negative scale — pinned by test.
  • Place re-entry re-arms api.on listeners (ledger 2145, dump b3959a68 — BUG SWEEPER's dead SOLO/DUEL menu and lost PvE-ladder MMR). Two engine legs, both fixture-proven red before the fix:
    • The delta-0 short-circuit ate the re-entry apply. unloadPlace destroys a place's entities but left the applied-generation record's binding intact; a re-entry whose spec content, residency key, and instance-places key all match the recorded generation short-circuited the parked placeResident re-apply, and the place never re-materialized at all — empty skeleton, no state, no ears. unloadPlace now drops TomeAppliedGenerationResource ("the world already IS this spec" was just falsified), so the next apply full-passes and the spawn loop's hasEntity checks rebuild the place. Cost: one full pass per post-unload apply; the gate re-arms at that apply's completion.
    • Module-memory persistence defeated the once-guard wiring shape. Compiled behavior modules live in the content-keyed behaviorCache for the process lifetime, so a module-scope once-guard (if (!wired[api.id]) { wired[api.id] = true; api.on(...) } — the exact shape api.on's own run_script teaching warn prescribes) never re-wires on a fresh materialization: the guard is already true, and the subscriptions died with the destroy. Now api.on stamps armedInOnSpawn provenance (via the existing onSpawn-context probe), the place-unload teardown PARKS non-onSpawn-armed subscriptions (TomeParkedEventSubscriptionsResource, threaded as DestroyEntityWithHookOptions.parkReentrySubscriptions through the cascade), and every later materialization at the same id — spec-lane re-expansion or a re-run producer's deterministic-id api.spawn child — revives them. onSpawn-armed ears clear as always: their producer re-runs (RUN inline server-side, the DEFER drain on clients), and reviving them too would double-deliver. Parked rows whose script's edit generation moved drop at revive (the edited script's fresh module re-wires itself); the park dies with the world-reset sweep.
  • Deliberately NOT fixed here, named in ledger 2145: the relay-tier journal-refold lane (BUG SWEEPER's actual tier) re-materializes entities through replication ingest, which never runs spawnObject — the DEFER-park needs extending to ingest-materialized hook-bearing entities in unhosted places. And cross-place emits into an unhosted place (the rank-write hole) exist independent of the deafness.
  • Parked re-entry ears now check behavior membership at revive (adversarial review of #12599 / ledger 2145). The park's edit-generation check only catches CONTENT edits of the same script; reassigning behavior: ["referee.js"] → ["other.js"] while the place is unloaded bumps no generation (referee.js never changed), so the parked referee.js ear revived onto an object that no longer runs referee.js and its handler fired on the new def — red-proven in place-reentry-park-revive-guards.test.ts. reviveParkedEventSubscriptions now takes the materializing def's behavior refs (spec lane passes def.behavior, the api.spawn lane passes resolvedSpec.behavior) and drops parked rows whose arming script left the list. Safe for every script-reachable sub: api.on binds to the arming entity itself and scripts hold no foreign full API (api.spawn returns an id string), so a sub's scriptRef is always one of its own entity's behavior refs; ref-less engine-side subs are exempt.
  • Review pins landed alongside: double-armed (onSpawn + update-once-guard) stays at exactly two ears across the round trip; the park's diverging-id residue (module-scope counter id schemes strand one entry per round trip until world reset — deterministic script Math.random re-mints identical ids and revives cleanly, so it does NOT leak) is pinned honestly; the pre-existing reset-lane deafness (resetTomeWorld drops the park while the once-guard module survives the content-keyed cache — update-wired ears never re-arm after an explicit reset) is pinned as it.fails so the fix flips it loud.
  • Torn-frame gate in client local-transform projection (ledger 2135, enfeul's furniture-sized frag grenade, dig 3e3d04e9). projectEntity derives Local* as inv(parentWorld) ⊗ childWorld; when the parent's World* wasn't available, every lane manufactured an identity parent — a missing parent entity fell into the "root" branch (Local ← World verbatim) and a present-but-unsolved parent divided by identity. Either way the child's world scale baked into LocalScale as a durable AOI-replicated authored-truth write; the next hierarchy solve recomposed parent × local and the child grew by the parent's scale per torn frame (2.3 → 5.29 → 12.2). The position and rotation lanes had the same flaw — a bake there teleports/mis-rotates instead of growing. Now projectEntity resolves the parent frame once (readParentWorldFrameIntoScratch): a parent that is missing, or present with NONE of the three World* rows, is a torn frame — the whole projection is skipped, nothing is written, and the entity stays queued (torn carry-over in the system loop) so the derivation re-runs when the parent's frame lands. Structural, not a sixth guard: the wrong write can no longer be manufactured in this shape.
  • Partial parent frames still project, deliberately. A parent with SOME World* rows keeps identity/zero fallbacks for the missing aspects — the same convention solveChild composes with, so the division inverts the compose exactly (round-trip stable). This is a legitimate steady state, not a tear: physics write-back mints WorldFeetPosition/WorldRotation but never WorldScale, so an unscaled dynamic-body parent (car-weld rigs) carries pos+rot rows forever. Pinned by test so the gate never tightens to any-row-absent and freezes those children.
  • Guard census (the managing-not-eliminating check, ordered by the mint): none of the five existing torn-frame guards dies under this gate — each closes a tear where the parent's rows are PRESENT but values are stale, mixed-authority, or in another basis: singleplayer short-circuit (#154, child rows hold parent-local coords pre-solve), god-mode-drag skip (parent moved mid-drag, child stale), stand-rider skip (child last-physics vs parent fresh-ingest), bone-subtree skip (#308, World = bone ⊗ local basis), eager attach compose (#160, attach window). All kept; census recorded in the gate's doc block. Deleted instead: the identity-manufacture branches themselves — the per-lane parent reads and their fallbacks collapsed into the single gated frame read, and the dangling-parent-as-root branch is gone from all three lanes.
  • Mixer channels take time — the pose-freeze / seek primitive (seconds into the clip). speed: 0 was already legal but only ever showed frame 0; the raw three.js equivalent is two lines (action.paused = true; action.time = 0.4), so this is an API gap, not a pipeline. time is honored on create AND update: a layer is born at the seek, an UPDATED value re-seeks the playhead, and a re-sent equal value never disturbs playback (scripts re-derive and re-send channel state every tick — the no-op law). A startTick restart (one-shot re-fire) lands on time, not 0. Negative clamps to 0 at parse. Plumbed through the whole seam: spec type + Zod schema, parseMixerChannel, updateChannel merge + mixerChannelsEqual (time-only updates are never swallowed; time: null clears the stored seek), the per-character mixer (clip-state.ts — ActiveClipLayer.seekTime tracks the last-applied authored value), and the horde batch path (skinned-batch.ts — same law, and a seek is an event: it resamples immediately instead of waiting out an anim-LOD hold, so the batch↔clone seam agrees on the pose).
  • Rail-lane audit closure (#12674, tucker's commission; audit doc docs/plans/rail-lane-audit-2026-08-11.md): every sendable message gets a lane or a loud refusal. Four verified gaps from the 13-agent rail/lane census (215 rows, 144 names, 25 candidates adversarially verified), each re-proven still-real at HEAD before building. Kernel parity: nothing new rides the wire.
  • The resurrection cure — the ghost-destroy adjudicator widens past god-mode (rail.destroy residual; tide's destroy-authority adjudication, the journal-resurrection class). judgeRelayGhostDestroy drops its god-mode gate: ANY outside-invocation destroy — script, run_script/exec, god-mode alike — of a departed/unowned journaled entity tombs, or intent-routes to the live owner; sim-lease claim capture stands aside on relay (claims can never confirm there — lease.claim is a dead lane), so captured destroys stop silently reverting. This completes the covenant family whose god-mode face already rode this draft (a98bed581a): the class was entities outliving their own DESTRUCTION — script/exec destroys of hosted/unowned journal entities minted no tomb on every promoted engine, and the host-adoption restate (relay-world-restate.ts) re-journaled the ghosts as fresh keyed heads on every churn, which is why world resets healed for only days at a time.
  • spawn:kernel:spec-save settles on the mobile embed (was silent park). GameHost (/embed/play) had no listener — a permissioned creator's relay-room edits parked after 3×30s and were silently gone on reload. VariantViewer's settle flow extracted to core/kernel/spec-save-relay (one implementation, two mounts); appId-less hosts answer a typed terminal host_has_no_save_lane refusal instead of parking.
  • tome.dm.notify gets a lane (was none → {local, dmNotify}). Egress consumes into an identity-stamped RPC on cf-relay's new 'dm' lane → kiln /api/relay/dm-notify → the shared studio-chat notify_dm door (source pinned 'tome'). Rate window + once-key dedup kiln-side (Redis, fail-open, claim released on failed delivery); failed DMs land in the runtime log instead of pretending they sent. Platform halves ride worker deploys, not this tarball; an old relay answers 400 "unknown rpc lane", logged verbatim as a failed filing.
  • rail.interactClaim journals peer-visible grants (was none → peer). Peers register (sender × target) grants in a kernel-mirrored table (relay-interaction-grants.ts, TTL/cap drift-pinned) honored at exactly the kernel-authorized rails — relocated hook terrain edits, dabs, and grant-sourced enterPlace converge room-wide and fold for late joiners.

Engine v5.2.21

Released August 7, 2026

  • When a database operation takes a moment longer than a script run allows, Savi now gets a plain explanation — the database is answering, nothing hung, just re-run — instead of a scary "hung await" error that used to send her hunting for a bug that wasn't there.
  • Physics (mantle worlds): characters and objects no longer catch on invisible snags while moving across smooth terrain and mesh seams, and worlds full of settled objects now truly go to sleep — an idle pile costs almost nothing, so big physics scenes stay fast.
  • Fixed a case where "I've reported this to the team" was wrong: in some multiplayer rooms, engine bug reports Savi filed looked sent but quietly went nowhere. Now she's told straight away when a report can't leave that room, so she can tell you the truth — and reports that CAN be sent still go through exactly as before.

technical notes

  • Exec budget abort with api.sql in flight names the fact — never "a hung await" (#12441, ledger 2092 fork B). sql-api.ts grows a register→settle in-flight lifetime (separate from the drain's take-and-clear pending set, whose taken-but-unsettled entries are exactly the invisible case); buildAsyncSettleAbortError probes listInFlightExecSqlWork at mint and, when non-empty, mints the honest verdict: call named, lane's bounded window stated (relay reply deadline 30s, direct door fetch 10s), rollback fact stands, action = re-run once settled. The 5000ms budget itself is untouched; the fire-and-forget drain abort already named its sql fact and is untouched. Origin: dump 339d9450 — published call shapes blew the budget by ~5ms, then settled after rollback, and the "hung await" text got the planned event filed as an engine hang.
  • mantle: box3d-guided fixes + frontier optimizations (#12311). Trimesh weld probes neighbor cells (box3d b3SpatialHash shape, 8-cell probe at cellSize = 2×tolerance) — seam vertices 2e-8 apart no longer cook to distinct representatives that read coplanar seams as boundary edges; 200k-tri mesh cook 946→475ms vs the 27-probe draft. Near-parallel edge-axis rejection at box3d's relative tolerance; face manifolds never stripped by edge axes in box-tri. Broadphase goes O(degree) per moved body. Sleeping worlds cost nothing (island-granular sleep; per-axis sleep extents). New pinned suites: wake-seam purity, sticky-zero scratch, tick-pair set, plane-lock extent, near-parallel edge rejection. Full box3d HEAD re-comparison recorded in docs/physics-native/box3d-harvest-notes.md (zero solver-constant drift; ζ=5 warm-start re-run rejected on new grounds; solver-sets rejection superseded by the derived-frontier design). frozen-receipts.ts retired.
  • reportEngineBug: ok:true must imply a lane (#12436, ledger 2089 — intake-death mechanism #2). resolveEngineBugDispatchLane resolves rail | sp-forward | none BEFORE the verb returns; sp-forward requires client + singleplayer + POSITIVELY-sniffed kernel wire + live connection (isGameDbForwardEligible's gate shape — an undetermined pre-sniff wire refuses rather than gambling, since a fire-and-forget command has no typed reject at the relay egress). Exec snapshots can't see lane facts (all live-channel), so the exec host stamps the live verdict into the snapshot-riding config mirror at snapshot cut (the lastReportAtMs pattern). Lane "none" returns the throttle-style structured failure Savi already relays; nothing minted, no floor consumed. dispatchEngineBugReport now returns whether the report left, and the belt-and-suspenders no-lane warn carries networkingMode + wire. Test table covers every session shape: each row either lands the incident in a store or returns the honest refusal — zero silent-drop paths. Completes postmortem 2026-08-07-relay-sp-dead-db-lane §4.1; the kiln RPC lane for relay filings stays §6.2's owned follow-up.

Engine v5.2.20

Released August 7, 2026

  • Cleaned up engine diagnostic noise that every game session was quietly emitting — no visible change in your game, slightly less background chatter from the engine.
  • While a model is still being made, its in-world stand-in now says so: "Making cat hero… takes a few minutes" instead of an endless "Loading…" — players watching a long cook know something real is coming and roughly how long, instead of reading the world as broken.
  • Fixed frame pacing on some machines (many Macs included) where the engine misread the screen's refresh rate as faster than it really is and paced the game against a budget the display never had — those machines now run at their display's honest rhythm.
  • When a save partially fails, Savi now gets told exactly which pieces failed and why — so she fixes the real problem instead of re-running things to figure out what broke.
  • A typo'd shape name in a spawn now fails immediately with the list of real shapes (and a "did you mean"), instead of silently spawning nothing and breaking the next save.
  • Notes and archived files in your scripts folder no longer break saving: a README or a .bak backup sitting in scripts/ used to make the game refuse every save read after an update — it looked like your character got completely reset. Those files are ignored now and your real scripts run exactly as before.
  • Box-bodied cars and characters no longer catch on invisible seams in flat terrain — no more phantom stops at low speed or bumpy hops at high speed on ground that looks perfectly smooth.
  • Reading a nested property path now just works: getProperty("primitive.width") answers what you wrote there. And bad values tell you what they wanted (a scale of "big" explains itself) instead of quietly shrinking your object to normal size.
  • Spline verbs are honest now: a wrong option (like a misspelled space or a bad offset) tells you exactly what it wanted instead of quietly doing something else. And offsets written as [x, y, z] arrays just work.
  • Fixed a bug where accidentally putting a function inside your saved state (even deep inside an object) could silently undo everything else your script just did. Now the function is simply skipped, everything else saves, and the log tells you exactly which key was skipped.
  • Deleting a state key that has a dot in its name now works: deleteState([["dotted.key"]]) reaches exactly the key you wrote, and if you spell it the old way the log tells you the working spelling. And handing the state verbs something that isn't a state object gets a clear "call ignored" instead of quietly wiping your state.
  • Coming back to your world after a disconnect no longer bounces you or your friends with "room full": a player who just dropped doesn't count as still occupying their seat while the game waits for them, so one-seat worlds stop turning away the very player they were made for.
  • Terrain reads stopped inventing answers: asking for the ground at a broken coordinate (NaN from a divide-by-zero, say) now tells you exactly that in getLogs instead of quietly answering "the ground is at 0".
  • When a bounds read comes back empty, the log now tells you why — a typo'd id, an object with nothing visible to measure, or a model that hasn't finished loading — instead of leaving you to guess which one you hit.

technical notes

  • lume alloc census logs only where armed (#12335, jure's catch in #w-rendering): wave 3 wired tickAllocCensus into the SHARED lume frame loop un-gated, so every prod client dumped a census line every 300 frames into Datadog. Now an arm-gate, armed exactly by the jsc-shell entry (the census's one consumer — the consumer declares itself, no env sniffing); every other bundle including prod web is a no-op. W4-PHASE-CENSUS never leaked (its ticker arms off the shell's __jsc* globals, verified null in browsers).
  • The in-world placard gains the cook register (r-426fb323, dig af2514aa, #12206): ModelLoadVerdict grows 'cooking' — the asset service's own lastHttpStatus 202 receipt (written only by the still-generating lane) walks through to the placard, which then names WHAT is being made and the honest time class ("Making cat hero…" / "takes a few minutes" — no fabricated ETA). Ledger 1675's register law holds: generation language only on surfaces that can prove a generation job from the server's own answer; unproven non-failed copy stays "Loading X…", dead checks unchanged. Two-line labels ride the existing lume text lane (layoutGlyphs newline split; anchor [0.5,0] grows the block upward off the hologram). Copy pins updated; MID_COOK fixtures carry the verdict the real service answers mid-cook.
  • Quality governor refresh detection rebuilt (#12346, jure's 120.1Hz-is-not-165Hz thread): the p10 floor estimator raced the fast tail of rAF DELIVERY jitter — worker rAF timestamps are callback delivery times, not vsync times, so late-then-on-time delivery manufactures intervals shorter than the display tick, and every misjudgment landed in the budget-tightening direction. Replaced with a mode-share + median estimator over the same 240-interval ring: the judged point must hold ≥25% of the window inside its band (fastest-first), between-points promotion reads the MEDIAN rounded down onto the vocabulary, and adoption residency is asymmetric by direction — down stays ~1s + the load-gate, UP takes ×3 sustained residency (this clock can fake fast, and a wrong fast judgment manufactures overload). All five field streams pinned as fixture tests (clean/jitter/double-fire/mac/rig); no presented-frame clock exists in the worker to confirm against — stated on the detector.
  • Persist verdicts name their failures (dig 9741709b, #12344): every per-mutation error entry carries target (the mutation's own id/path/place/behavior-target/starter-id — one generic probe, no per-kind switch) on all three error lanes (throw, row gate, batch gate) and in the operator reject log; the studio-chat verdict formatter reads [#i kind(target)] path: why, mutation-first, every failing mutation named when ≤10 failed and first 10 + explicit (+K more failed mutations) beyond. Savi's deliberate bisection re-runs — re-running a batch purely to extract WHICH mutation the old "N of M failed" verdict withheld — lose their reason to exist.
  • api.spawn refuses an unknown primitive.kind honest-early: the curated primitive validator throws at call time with the persist gate's own vocabulary (derived from PrimitiveSpecSchema, plus a did-you-mean) instead of letting the bad kind detonate the whole persist batch later. setProperty lanes untouched.
  • Worker-layer fix recorded here because creators experience it as engine behavior (rides kiln deploys, not this kernel tarball; ledger 2068, dig 095031f8): a non-JS file in scripts/ (README.md, archived.js.bak — Savi's own documented practice writes these) failed acorn's parse in kiln's published-SQL manifest extractor, marked the whole manifest PARTIAL, and the cf-storage door then refused the game's entire player SQL lane — save reads at join refused, presenting as a full character reset at the update boundary. The extractor now consults the file's NAME at the parse-failure/size seam only (isJavaScriptShapedPath, kiln core/game-db/published-sql-manifest.ts): .js/.mjs/.cjs and extensionless entries that cannot be read still fail the lane closed (they are scripts the engine may run), while non-JS-named files are skipped. Skipping never admits a statement — the manifest is an allowlist, so anything unextracted refuses per-statement at the door. A non-JS-named file that PARSES as JavaScript still extracts under normal posture rules, because the engine's module rail resolves exact-named keys verbatim.
  • Mantle box-character wedge/pop on flat heightfield internal edges cured (ledger 2080, tiger's nightline; conviction from the #12391 A/B harness):
    • Mechanism: the character controller's contact collection (move-shape.ts collectContacts) ran GJK/EPA against each heightfield/trimesh triangle as a bare 3-point hull (queries/primitives.ts) with no active-edge information — the narrowphase's FixNormal law (static-trimesh.ts) never reached the CC path. A box parked at the padded gap near the next cell's shared edge got a closest-pair normal of box-bottom-edge vs triangle-edge — near-horizontal, below maxSlopeCos — a manufactured wall. At 12 m/s the plane solver blocked all forward motion forever (0.091 delivery, 133-tick wedge ~0.02 m short of integer cell boundaries); at 51 m/s the same plane converted forward velocity into upward slide (+0.42 m single-tick pops, grounded flap). Capsules escaped geometrically: the bottom sphere keeps seam normals inside the walkable cone.
    • Fix: collectConvexContact now applies the narrowphase's active-edge FixNormal law to per-triangle static contacts — when the contact's barycentric feature region has only inactive edges, the triangle face normal replaces the manufactured one and the separation is projected (gap ×= dot); within 1° the snap is unconditional (the same float-fuzz kill that keeps flat-ground normals bit-exact). Edge activity comes from the enumeration that produced the triangle (queries/primitives.ts currently-visited-triangle context): cooked masks for trimesh, the on-the-fly neighbor-normal rule for heightfields (static-heightfield.ts heightfieldTriangleActiveMask).
    • The #12391 divergence pins regenerated toward the rapier column per the suite's own header rule: mantle @12 delivery 0.091 → 1.000 (streak 133 → 0), mantle @51 delivery 0.928 → 0.99999 (maxRise 0.421 → 0.000, flap 12 → 0, airborne 30 → 0). The race run now crosses the two-chunk seam at x=0 with min tick delivery 0.999998.
    • Determinism goldens regenerated (runtime-browser-scenario.ts): characters/narrowphase/solver/world sections moved (+ nightly chain.queries); finalCharacterX moved by ~4e-5 m; every other section hash and every physical metric stayed bit-identical. V8/JSC receipts verified identical.
  • get/setProperty asymmetries cured (ledger 1947, tucker's ObjectAPI audit batch 1):
    • Dotted read parity: readDottedProperty (properties.ts) is the read half of the dotted machinery — same path grammar as buildDottedPropertyMerge (split at first dot, same model.materials → parts canonicalization) wired into getProperty and getObjectProperty. The write half existed for years; the read half kept answering undefined and scripts "fixed" working code.
    • Non-string keys: one teach rail (warnNonStringPropertyKey) across getProperty/setProperty/getObjectProperty/setObjectProperty — was null.startsWith / key.startsWith is not a function raw TypeErrors.
    • The null promise honored, and truthfully scoped: setProperty("scale", null) clears to neutral 1 (was a TypeError at value.x in the writer, contradicting the boundary's own "Pass null to clear" log); the undefined-value log now says "most properties accept null to clear" instead of promising it universally.
  • Silent numeric coercion cured at the property boundary (ledger 1941):
    • CURATED_UPFRONT_PROPERTY_KEYS — curated validators now run up front on live writes for audited keys (rollout gate, spawn() has always run them all): scale first, whose garbage writes used to be rescued to {1,1,1} by quantize + component sanitize with zero log traffic (live-probed: an authored scale-2 box silently RESET to 1). The scale validator refuses non-finite numbers and partial/non-finite {x,y,z} with verdicts naming the value. A blanket gate was deliberately not shipped: writers like visible: Boolean(value) are lenient on purpose — widening is one line per key after a leniency audit.
  • Silent numeric coercion cured at the state choke point (ledger 1941, the 1943 rail's numeric kin):
    • quantizeStateValue/quantizeTrackedStateWriteValue turn non-finite numbers into null (JSON's own semantics) at any depth and teach through the json-safety rail with the path (kind: "nonfinite"); isSimplePatch routes non-finite to the slow path (top and nested level — the same fast-path leak shape as the 1943 symbol kin); the tracked-proxy set trap applies the same law at the proxy door.
  • Two fixture-stock test.fails pins (dot-path-round-trip, nan-state-coercion) flipped to unexpected-pass and were promoted to real assertions per that file's protocol. One old test pinned the silent 0 (getState direct writes normalize non-finite numbers) — flipped to pin the null + row citation.
  • Docs ride same-PR (ravi r-4f375dc0): types.ts docstrings (getProperty/getObjectProperty dot-path parity, patchState/replaceState non-finite law), skills/api-reference.md, regenerated TomeAPI.md + tome-api-prompt.ts.
  • Spline family contract cures (ledger 1946, tucker's ObjectAPI audit batch 1 — the validation half; the mesh-rebind-on-spline-move half is a separate engine dig):
    • placeOnSpline reads offset as {x, y, z} OR [x, y, z] — the tuple used to fail the Vec3 guard and be skipped silently while the call still answered true (the boolean is the caller's verification; success now means effect). A malformed offset refuses the whole call: false + a verdict naming the accepted shapes, no partial placement.
    • projectToSpline accepts [x, y, z] points; a malformed point answers a verdict-carrying warning instead of a bare null indistinguishable from "no spline".
    • options.space is validated across sampleSpline/placeOnSpline: anything but "normalized"/"distance" refuses (null/false + a verdict naming the valid values) — space: "furlongs" with at: 21.71 used to clamp silently to the spline's END under success: true.
    • Non-finite at (NaN/Infinity) refuses with a verdict instead of a bare null.
  • Two fixture-stock test.fails pins (spline-offset-silent-skip, spline-space-fallback) flipped to unexpected-pass and were promoted to real assertions per that file's protocol.
  • Not new in this change but pinned by its tests: non-string spline object ids (getSplineLength(5)) teach-and-refuse via the ledger-2018 id-resolver guard instead of throwing Q.includes is not a function.
  • State writes filter unstorable leaves (functions, symbols) at EVERY depth, not just the top level (ledger 1943, tucker's ObjectAPI audit batch 1). Before: patchState({ deep: { bad: () => 2 } }) slipped the top-level-only filter, the function survived into the stored bag, and the whole script's mutation batch rolled back at the serialization seam — one nested function nuked every other mutation in the run_script. Now the drop costs exactly its own key (array slots become null, JSON's own semantics), the rest of the write lands, and a getLogs warning names the dropped path (state values must be JSON-safe: function at "deep.bad" was dropped — the rest of the write landed…). Applies to patchState/replaceState/setState, setObjectState/patchObjectState, spawn state, and direct tracked-proxy assignment (api.getState().bad = fn is refused with the same teach instead of storing). Symbol values also used to sail the simple-patch fast path straight into the stored bag; they filter identically now.
  • The old top-level-only warn ("Functions cannot be stored in state and were filtered out") is replaced by the per-path JSON-safety teach rail (same rail as Map/Set/Date normalization: runtime log → getLogs and run_script's returned logs, cooldown-deduped per script per key).
  • One path grammar across the state verbs (ledger 1942, tucker's ObjectAPI audit batch 1):
    • normalizeDeleteStatePaths (the one place dots ever split) now also accepts segment arrays — literal key chains that map 1:1 onto the wire/mutation format (paths: string[][]), so the API-level grammar converges with the recorded-mutation grammar and keys containing dots are reachable. patchState keys are LITERAL (unchanged — now said out loud in the docstring).
    • teachDeleteStatePathObstacle classifies no-op delete paths against the live bag: literal-dot shadow (teaches the segment-form spelling verbatim), array obstacle (arrays are leaves per the merge law — replace wholesale), primitive obstacle. Missing paths stay silent (documented idempotence). Wired into applyDeleteStateToEntity behind a changed-nothing guard so single-path deletes that land pay nothing.
  • One door for the map-taking verbs (ledger 1944):
    • resolveStateMapArg validates the state/patch argument (tracked-state proxies unwrap first) across replaceState/setState/patchState/setObjectState/replaceObjectState/patchObjectState; refusals teach with the verb's own name and the value. Kills three lies at once: number → silent state CLEAR, string → index-keyed bag, no-args → raw Symbol(tome.trackedStateRawTarget) TypeError leak.
    • replaceObjectState implemented as setObjectState under replaceState's name (shared body applyObjectStateReplace; each alias keeps its own name in the teach rails; read-only prototype covers it). The verb family now reads replace·set·patch·delete on self and cross-entity alike.
  • Docs ride same-PR: types.ts docstrings (patchState literal-keys law, deleteState/deleteObjectState grammar + segment form, replaceState argument law, replaceObjectState), regenerated api-reference.md + TomeAPI.md.
  • 17 new pins in state-verb-grammar.test.ts (bare-world, fixture-stock pattern).
  • A suspension-stamped dead-socket ghost no longer holds a capacity seat at the admission door (ledger 2072, dig bd369775). On ws_close a Ready connection stays grace-held (phase Ready, detachedAtMs set) for the 20s disconnect grace, and in client-auth worlds the session is suspension-stamped into TomeSuspendedSessions on detach (takeover grace 0) — the engine's own declaration that the owner is gone and the envelope is host-simulated place remainder. The door kept counting that ghost against spec.routing.maxPlayers anyway, so on a maxPlayers:1 world every disconnect made the room unjoinable by any other clientId for the whole grace window (LFO playtest: 11 room-full rejects in one window, savi's router minting a fresh container per bounce — ≥6 concurrent containers of one app, two testers structurally unable to share a world). The capacity count now skips a connection that is BOTH detached and suspension-stamped. The reservation survives where the occupant is plausibly present: an unstamped detach ghost (no avatar, or a non-client-auth world) and a hidden-tab suspension on a live socket still count. The ghost's own owner was never door-blocked (same-clientId reattach resumes past the count — behavior unchanged, now pinned by test). Deliberate residue: a stamped owner who returns after a newcomer took the seat resumes into a transiently over-cap room, bounded by the disconnect grace — favored over turning live players away for a ghost.
  • The room-full reject is a structured log event (netcode.connection.rejected_room_full with roomId/clientId/connectionId/maxPlayers, via logger.warn) instead of a console.warn whose fields object was dropped by log ingestion — the dig's named receipt gap: eleven rejects, none attributable to a client.
  • Terrain/water point reads refuse non-finite coordinates (ledger 1945, tucker's ObjectAPI audit batch 1; the 1941 silent-coercion class, terrain specimen): getTerrainHeight, getWaterLevelAt, getTerrainNormal, getTerrainMaterial all answer null for NaN/Infinity x/z through one loud-once teach rail (teachTerrainProbeNonFinite) naming the verb and the coordinate. NaN used to flow into the sampler, the generator-fault rail clamped the non-finite result to 0 — blaming the CREATOR's generator for the CALLER's coordinate — and getTerrainHeight answered a silent 0 against its own "(never a silent 0)" docstring. The sibling getWaterLevelAt already had the finiteness guard; the cure is symmetry.
  • getTerrainNormal (ObjectAPI + CameraAPI) returns Vec3 | null: null for no-terrain and non-finite coordinates — the same null semantics as getTerrainHeight. The old {x:0, y:1, z:0} phantom-flat answer read as plausible data over places with no ground at all.
  • One fixture-stock test.fails pin (terrain-height-nan-silent-zero) flipped to unexpected-pass and was promoted to a real assertion per that file's protocol.
  • Docstrings + regenerated api-reference/TomeAPI teach the shipped null semantics (ravi r-4f375dc0 row 2, same-PR law).
  • getWorldBoundsBox null polysemy cured by naming, not by return-shape change (ledger 1948, tucker's ObjectAPI audit batch 1; kin of the 1931 enumeration family): null used to mean five things — missing object, no geometry, light-only, "", null-arg — while the docstring reserved it for still-loading and the promised warning fired for exactly that one cause (the rail was gated on readPendingModelId). The return type stays WorldBoundsBox | null (every existing if (box) guard keeps working; the platform's own register for this class — voxel probes, terrain reads — is null + a named verdict, not a discriminated wrapper).
    • teachWorldBoundsNull — the r-0223b62a loading rail generalized into a classifier, loud-once per (entity, cause, script): "loading" keeps the original text; "no-geometry" names what null means (nothing to measure, never zero size) and, for spline owners (resolveObjectSplineValue), names the generated sibling id prefix that carries the rendered surface.
    • teachBoundsMissingTarget — the missing-object cause teaches at the API boundary with the caller's spelling ("" included), mutation-warn throttled per id.
    • Non-string ids (getWorldBoundsBox(5) / (null)) were already cured by the ledger-2018 id-resolver type guard — pinned here so the Q.includes crash can't regress.
  • Docstring (ravi r-4f375dc0 row 3 — the doc shape this cure decides): "null always means 'no answer', never 'zero size' — and every null names its reason in a getLogs warning", enumerating the reasons. Regenerated api-reference.md + TomeAPI.md.
  • Remainder, named honestly: owner-answered spline bounds (the spline object itself answering its surface's box) is the same generated-SIBLING architecture dig as the 1946 mesh-rebind remainder — the loft mesh is ${owner.id}__spline__* with no parent link, so no subtree union can see it. The verdict's redirect is the working call today; the row stays open for that dig.

Engine v5.2.19

Released August 6, 2026

  • Small windows render sharp: shrinking the game window no longer melts distant detail into mush — thin lines, far-off structures, and in-world text stay readable at small window sizes, because the engine now refuses to drop its internal rendering resolution below a legibility floor on ordinary screens.
  • Places stop coming back empty: after a server crash or reconnect, a place players were standing in could lose all its objects until the room recycled — the world's cleanup sweep now notices an occupied place with no live copy and brings its objects back within moments.
  • Fixed an issue where Savi wasn't able to manage your game's database properly — database setup and reads/writes she does on your behalf now land reliably in multiplayer rooms.
  • Fixed an issue where crons weren't running properly — scheduled game work (timed events, recurring jobs) now fires the way your scripts scheduled it.
  • Savi finishes her own checklist: when Savi completes something from your world notes (tab+f), she checks it off herself instead of leaving every done item hanging as "attempted" for you to sweep up — taste calls and anything you sent back for a redo still wait for your verdict.

technical notes

  • Engine test/verify infrastructure riding this version, no shipped-bundle behavior change: probe harness repaired for the relay era (four breaks fixed, relay-ground seams added, dead frozen-world parity rows re-pinned — #12249) · verify-fpv-transparent device-in-the-loop A/B pin at the transparent×camera-attach seam (#12255 — ledger-2055's deterministic code-path-skip theory rejected on device evidence; the harness caught the field-signature transparent-lane dropout in miniature and soaks for it with --runs N) · sql-api relay-digest race flake killed at the await (#12223) · mint tooling refuses the repo-root .changesets/ trap where records silently never minted (#12229).
  • lume: absolute internal-resolution floor in the quality governor (#12252): the ladder cuts renderScale as a FRACTION of the canvas, but thin-content legibility runs on absolute pixels — ×0.55 of a 1707×916 window is a 939×504 scene pass (far wires gone, world text mush) while the same fraction at 4K is fine. Effective scale = max(ladder scale, 1440 / canvasWidth) clamped ≤1, folded at the one seam where the fraction becomes a buffer size (renderer.ts frame args), so rung cuts, the judder hold, persisted landings and ?rungPin are floored by construction and live resizes track frame-by-frame. DPR > 1.5 passes through untouched (the deliberate density law). New wires-road render-harness fixture pins the class at the repro window (unfloored deepest rung fails 3/3 far-content probes; floored reads all 3).
  • Res-floor follow-up (#12262, review findings fixed forward): ?sceneScale pins are held as hrefScenePin and bypass the floor at the consumption seam (the rig's A/B pins again); F2 names the floor as the true asker when it clamps to ×1 ('internal-width floor 1440px — ladder asked ×0.55') instead of reading 'session opening'; the seam doc's shared-clamp claim trued in both homes (shared THRESHOLD, not shared input — the divergence is deliberate).
  • The '[Tome] Juice call has no audience target' teach-rail line no longer floods the prod error lane (#12230, ledger 2045; 61 lines/78min from one app): it now rides the standard 30s content-key window (claimRepeatingReportKey — one claim gates the getLogs half, the ledger-1671 diagnostic forward, and the DD line) at WARN (a teach is not an engine error), carrying an unthrottled running classTotal so consecutive lines still answer rate questions. run_script overlays take only the runtime-log half (one returned teach per exec, no DD line, no stats write through the transaction merge). Sibling teach lanes untouched. Red-first: a 20-call storm mints ONE warn line; past the window the next carries classTotal=21.
  • placeCleanupSystem keeps the occupied⇒resident invariant in BOTH directions (#12228, ledger 2044, dig de367ce4): a session avatar standing in a place with no residency entry was a permanent hole — the sweep only REFRESHED existing entries, applySpec skips non-resident places, and no writer ever re-created the entry, so the place's objects existed in the spec doc and nowhere else (Fire Nuke Island after a crash-reboot: 44 spec rows, 0 live). The sweep now creates the missing entry from the SessionOwner⋈PlaceMembership occupancy index it already maintains, gated on the place existing (authored or live instance row), loud on the recovery. Red-first pins in reconnect-place-residency.test.ts, including current join semantics (spawnPlayer re-seats at the default place) as an explicit receipt.
  • Worker-layer completion of the relay game-database lane, recorded here at tucker's direction (creators experience these as engine behavior; the fixes ride worker deploys, not this kernel tarball): #11685's player SQL lane got its config half everywhere (#12220 — STORAGE_DO bound on staging+prod relays, prod room-mode + sql-manifest rows), cf-edge stopped knocking fresh games off the relay (#12250 — ABSENT engineSemver means UNPINNED, not unprovable; the P0 floor fix), and the prod relay now deploys on the same train as the edge that routes to it (#12236 — the 5.2.18-promote incident where sql-using rooms landed on a relay with no storage doors can no longer recur by omission). Scheduled work (crons) on relay-hosted rooms rides the same lane.
  • api.sql array-misread guard (#12224, incident dde237bc 'Be Somebody'): every tagged-template SQL library resolves an ARRAY, so sessions read .length/r[0] straight off SqlQueryResult and the misread was perfectly silent — that incident's session concluded "api.sql is a silent no-op" and rebuilt persistence AROUND a working database. mintSqlQueryResult now defines a non-enumerable .length getter that throws the shape teach at the exact property that carried the misread; all data paths untouched (own non-enumerable props ride none of them). Iteration needs no guard (for…of on a plain object already throws loud) and a Symbol.iterator trap is deliberately absent (it would break honest feature probes). Plus the 501→unconfigured remint pin on the relay lane (game-db-relay test).
  • World notes: Savi checks off her own finished work (#12231 — contract 8's missing half; jacob's ruling 2026-08-03: "she can do that — this is her todolist"). The studio-chat mutate route's lane guard refused every Savi-sent 'done' (all 50 closed rows on the reporting app were creator- or wisp-closed; Savi's finish could only park a row at 'attempted'), which also made a wisp she dispatched more able to close rows than Savi herself. Now: kiln's markSaviTodoDoneBySavi moves → done from any open state (note required, recorded as a completed attempt — honest trail for reopen; CAS pins read status + owner_wisp_id IS NULL so wisp-parked rows refuse), the mutate route dispatches status 'done', and the world-notes skill teaches the done/attempted split ('done' for plain finish lines she saw land, 'attempted' for taste calls and any redo of a row the creator bounced — their eyes close disputed rows). Creator reopen/bounce stays the override; no creator-surface UI change (savi-closed rows ride the exact read path wisp/creator closes already ride). Rides the skills manifest + studio-chat/kiln deploys.

Engine v5.2.18

Released August 5, 2026

  • Sounds can now stick to the thing that made them: playSound(clip, { position, follow: true }) keeps a sound on a moving object instead of leaving it behind at the spot it started — your own footsteps, engine hums, and whooshes stay with you instead of trailing behind as you run.
  • Spatial audio now always hears from the camera you actually see. Games with custom mouse-orbit camera scripts could previously get subtly wrong or mirrored panning while everything looked perfect — that seam is closed.
  • Changes your game's scripts make to the game's own setup — input bindings, UI, camera, terrain settings, and more — now survive saves: a script could change these live and see the change silently erased the next time anything else saved the game. Now they stick.
  • Games with lots of moving objects spend less engine time per frame on internal bookkeeping — positions, rotations, and scales are read without generating garbage-collector pressure, leaving more headroom for game logic at the same object count.
  • Particle effects can finally layer in front of 2D backgrounds — spells, explosions, and sparkles in a 2D game draw in front of backdrop art instead of always hiding behind it. Effects and sprites now sort together by world position, so an effect between two scenery layers lands between them.
  • Heat-haze and shockwave-style distortion effects no longer flash a ghostly rectangle that erases the effects behind them when their ripple settles to zero — an idle distortion is now invisible, exactly like it should be.
  • Game databases work in relay multiplayer: scores, inventories, and anything your game saves with SQL now actually persists on relay rooms — including schema setup, which happens automatically even in rooms that are nothing but players' browsers.
  • Performance readings got honest on iPhone: a GPU timing quirk could report absurd frame costs (literally the phone's uptime) — those readings are now filtered out at the source, so performance snapshots tell the truth.
  • Games sit lighter in phone memory: an idle world no longer keeps ~24 MB of background job machinery resident, which means fewer iOS tab reloads on long sessions.
  • One over-sized background job can no longer silently break all terrain and asset loading for the rest of a session — it now fails alone, loudly, naming what got too big. And the "server physics is paused" notice stops promising a wait will fix itself when it provably won't — past the warm-up window it reports the measured pause and routes to a bug report.
  • Timed entities actually expire now, even when a player's device wedges: projectiles, pickups, and effects spawned with a lifetime no longer pile up frozen in the world when the machine simulating them stops responding — the server cleans them up at their deadline.
  • Changing another object's state now just works everywhere you can see it: edit .state on anything a query or a collision hands you and the change is real — no more edits that silently vanish because they touched a copy.
  • Bot-heavy and query-heavy games get faster: reading state off query results no longer copies the whole state bag per object per tick, state writes do half the work they used to, and Savi's live edits over big worlds respond quicker.
  • Editing scripts on big worlds feels snappier: each save no longer recompiles every unchanged library module from scratch, so the little freeze after an edit on script-heavy games shrinks to just the code that actually changed.
  • Pinch zooms the game, not the page: every game gets two-finger pinch (and mouse-wheel) zoom by default — camera scripts that read the zoom channel keep full control of what zooming means, and games whose cameras ignore it get a sensible built-in zoom on phones and tablets.
  • Big multiplayer worlds got faster to join and lighter to run: players now download and stream just the area they're in instead of the whole room, so joining a large world is quicker and crowded rooms waste far less bandwidth on movement nobody can see.
  • Multiplayer rooms open faster: the world download now starts the moment the connection begins instead of waiting for the handshake — worth up to nearly a second on mobile connections.
  • Multiplayer movement smoothed out on relay rooms: other players no longer freeze and teleport when they start moving, stop, or change direction — walking in any pattern now streams as smoothly as walking in a straight line always did.
  • Music works in relay multiplayer rooms: jukebox tracks, stems, and fades now play for everyone — including players who join mid-track, who come in at the right point in the song instead of silence.
  • Script-created things stop vanishing in relay multiplayer: cars, pickups, and anything a script spawns when a place starts now come back correctly when players travel between places — leaving and returning no longer leaves the world permanently emptier.
  • Multiplayer ghosts are gone on relay rooms: a player who walks into your area now reliably appears, and one who leaves reliably disappears — no more invisible neighbors or lingering copies standing where someone used to be.
  • Relay multiplayer got dramatically cheaper on the wire — player movement and world state now ride a compact binary lane (3–5× smaller), and catching up after joining compresses — so rooms feel smoother on weaker connections and mobile data.
  • Big terrain rebuilds can't knock a room over anymore: when a whole world's ground rebuilds at once, the server paces the work instead of freezing — no more "the room restarted and everything spawned in it is gone" after a heavy terrain edit.
  • Games with lots of moving objects spend less time in memory cleanup — motion updates now recycle their bookkeeping instead of generating garbage every tick.
  • Things spawned right after a save now come alive immediately — on script-heavy worlds, entities created during the few seconds after an edit no longer stand frozen (visible but running no scripts) until the engine finishes recompiling.
  • 2D sprite edges are clean again — characters and props no longer wear a thin halo of sky color around their silhouettes when standing in front of tilemap ground.
  • Editing large worlds is faster: applying a change to a world full of roads, power lines, fences, and rooms no longer re-derives every generated piece on every edit — an edit now costs work proportional to what actually changed (~4x faster applies on lowering-heavy worlds, and burst edits like big builder-script runs spend far less time in bookkeeping between steps).
  • Heavy particle effects and layered music run smoother — ribbons, trails, and audio-rich scenes shed a large share of their per-frame CPU cost, with identical visuals and sound.
  • Games with several AI-generated or uploaded 3D models hold dramatically less memory on phones — the engine now frees each model file's raw bytes once its textures reach the GPU instead of keeping whole files around, which is a big step against the "game reloads on iPhone" class of crash. Busy building sessions also idle lighter: landing model metadata no longer re-scans all your scripts, and big boot bursts no longer leave permanently enlarged buffers behind.
  • Long sessions in sound- and media-heavy games use much less memory: decoded sound effects, warmed video clips, and edited-away material shaders are now released when nothing is using them (they reload on demand), which especially helps phones stay alive in big games.
  • Games with lots of timers — every() loops, staged builds, scheduled waves — no longer pay a per-tick sweep over every armed timer, and the engine's own tick loop allocates far less, so long sessions hitch less from garbage-collection pauses.
  • Invisible rotated walls: static boxes spawned with a yaw could, around busy spawn bursts and reconnect churn, briefly (or in bad cases permanently) collide as if unrotated — cars slamming into walls that measurably weren't there. Static colliders now always converge to their authored rotation as soon as it arrives, no matter what order the network delivered the pieces in.
  • Switching a world's terrain type live actually works now — going from tile-based ground to heightmap hills (or voxels) no longer silently keeps the old ground stuck in place until the room restarts. And if a terrain setup ever fails to install, the log now says which place and what that means, instead of failing quietly.

technical notes

  • playSound gains follow: true (row 2037, dig 55122dec). A positioned one-shot that names it mirrors its SOURCE entity's WorldFeetPosition while the voice lives — the primitive-not-pipeline fix for self-emitted movement sounds (installation-03's footsteps: spatial plays pinned at boot-plant positions trail opposite the mover's velocity for the life of every clip; bystanders hear correct physics, the mover hears their own boots behind them). Mechanism is the one loops already use: audio/prep re-reads emitter positions per frame, so the fix is a position mirror on the pooled carrier — spawnPlaySound records follow + the source entity in the one-shot registry (OneShotSoundEntitiesResource), and a new client system (tome/one-shot-sound-follow, renderPrep −110, ahead of audio/prep at −100) copies source→carrier each frame, making a follow voice exactly as fresh as an audio: loop on the mover itself. A source that despawns mid-clip stops mirroring and the voice finishes at its last position (one-shots outlive their emitter, by design). Version boundary pinned red-first: flag absent = byte-identical wire and behavior (unset one-shots stay world-pinned); the option rides the wire only on positioned non-loop plays that named true (unpositioned follow teaches once and lowers — non-spatial plays are already ear-locked; loops lower silently — tracking loops are the audio: component's lane). Pins: tome/__tests__/playsound-follow.test.ts (wire shape + client mirror, both halves).

  • Audio listener basis prefers the renderer's displayed camera (row 2036, dig 55122dec). readListenerTransform/readCameraTransform (engine/audio/prep.ts) now build the listener pose from viewState.rendererTransform (pos + rot) when renderer feedback exists, falling back to the script-authored viewState, then the entity transform — the exact preference the aim basis has used since camera-derived-axes' resolveViewState. Closes the latent audible-camera ≠ visible-camera seam: undeclared mouse-orbit rigs discard authored rot for display, so a camera script with missing or convention-flipped rotation writes produced wrong/mirrored audio with flawless visuals and correct aim, silently. Explicit AudioListener.forward overrides (the 2D fixed-basis law) still beat every camera-derived basis. Pinned red-first in engine/audio/__tests__/prep.test.ts (renderer-preferred, authored fallback, explicit-forward precedence, DrawCamera fallback path).

  • Savi surfaces: follow in the playSound/playSoundAt option types + @tomeapi doc (api-reference regen), and the audio skill's world-sound section names the trap and both exits (follow for self-feedback, or non-spatial).

  • Behavior-rail told-success-never-persisted — class elimination (ledger 2034, dig 4bd40250; second incarnation of the 9b58cad7 patchRouting hole). A spec-doc write from a tracker-less behavior script (api.patchInputs, and eighteen siblings) applied to the live spec, returned success, and never entered the durable stream — so every strictly-newer durable version rebased the section wholesale (inputs: next.inputs ?? prev.inputs) and wiped the write: annias patched 28 input actions from a behavior manifest and watched four Savi-churn spec applies erase them in 19 seconds, forever, across 190+ versions. The cure is structural, not another hand-copied tail: recordMutation itself carries ONE shared behavior-rail persist epilogue — armed trackers (run_script / god-mode) still own their batch; client-auth behaviors forward source-stamped (the ledger-1653 seam, now tier-complete); everywhere else a mutation classified persistable by isGlobalSpecMutation (the same classifier every other rail rides, with 9b58cad7's compile-time exhaustiveness gate) enqueues for the room's drain. The seven per-verb tails (patchEngine/patchRouting/places/mint) collapse into it, and the nineteen measured holes close: setScript, deleteScript, patchInputs, patchGodMode (+brushes), patchUi, setCreatorTab, patchTerrain, patchPlayer, patchCamera, replaceCamera, addBehavior, removeBehavior, buyables ×3, jobs ×3, replaceSpecWithStarter. A contract sweep derives its required set from the classifier, so a new spec-doc kind cannot ship without a behavior-rail persistence story (or a named live-only exception — patchAtmosphere's session overlay, which durable rebases deliberately cannot wipe). Falsifies: a bound action patched from behavior context vanishing on the next spec version.

  • Query iteration SoA component VALUES are flyweights (ruled 2026-08-04, squeeze hunt ECS core). The engine-internal QueryResult iterator contract narrows: soa3f32/soa4f32 columns (WorldFeetPosition, BodyPosition, WorldScale, WorldRotation, LocalFeetPosition, LocalRotation, LocalScale) now hand out ONE reused value object per column per QueryResult, refilled per row inside fillRow — the last per-row mint #12093 left standing (one {x,y,z} per SoA row per pass; a 20k-row soa3 query paid 20k mints per iteration, now 1 per column per QueryResult). toArray() keeps fresh mints per row and stays the retain-safe form. Audit receipts (bound: every .query( call site across apps/cf-kernel/src including tests and benches; positive control: the known SoA-tuple site tome/api/query-utils.ts:554 and the flyweight row-identity pins were both hit): exactly one non-test SoA-tuple iteration exists (query-utils.ts emitFullScanMatches — consumes in-loop, scalar emit, no retention), all test SoA queries use toArray(), no import aliases of the seven SoA components, no multiline query calls, no generic wrapper passes SoA tokens into world.query. Zero retainers found, so no consumer migration was needed. The script-facing boundary stays fresh-mint by construction and is now named in the QueryResult doc: api.query builds its own results (query-utils.ts buildQueryResult captures scalars and lazily mints an escaping Vec3), the exec overlay's query builds its own rows from staged values, and world.get mints per call — the flyweight never crosses the ECS layer. This change is invisible to behavior scripts and run_script.

  • Pin flip (documented per the ruling's duties): the retain-safety pin was documentary — the QueryResult interface doc ("SoA columns a fresh object per row", world.ts) plus a comment in determinism-pins.test.ts; no executable assertion pinned value freshness. Both texts now state the flyweight contract, and a NEW executable pin ("SoA column values are per-column flyweights during iteration; toArray mints fresh values", determinism-pins.test.ts) asserts one reused value object per column with correct per-row contents, and fresh distinct objects from toArray().

  • drainEventAdds returns a shared frozen empty array when nothing fired (the common case for its ~11 per-tick/per-frame drain sites — juice ×3, sound-loop ×3, purchase prompt, renderer particle burst, audio prep/deriver: ~400-800 throwaway arrays/s per session at rest, now 0), and its declared return type is readonly StickyEventAdd[] so tsc enforces the read-only caller contract mechanically (all 7 production call sites audited read-only; a caller pushing onto an empty drain now throws in strict mode instead of silently cross-contaminating drains). sound-loop-ingest processes its three juice drains in place instead of spread-concatenating them into a fourth array per server tick.

  • Replication drains refill a persistent changedComponentIds scratch in place instead of minting Array.from(set).sort(freshClosure) per drain — both the ECS-level drain (engine/ecs/replication.ts) and its production twin (runtime/replication/room-replication.ts, one drain per AOI-bucket class per egress tick). Consistent with the delta-is-reused-scratch contract already documented in replication-delta.ts; consumers (room-wire-codec frame encode) read synchronously within the same frame build.

  • behavior-update's per-tick debug readout (TomeBehaviorUpdateDebugResource) mutates one engine-owned resource object in place (ensure-once + six field assignments at all four system exit paths) instead of minting a fresh object literal per tick per world. Reader census: the sim-probe markers handler (structured-cloned at post time), tests, and the behavior bench all re-read the resource per observation; none diff object identity across ticks; behavior-update never runs inside an exec overlay.

  • New bench coverage for the two blind spots the sweep named: ecs/query gains query/1-soa3 (an soa3f32 population — the storage class every transform/pose query actually iterates; the old fresh-mint path measured 283µs/pass vs 254µs flyweight at 20k rows, mint kill 20k→1 per pass) and a churn/commit+churn/query pair (one spawn+despawn per iteration invalidates every cached query shape; measured ~2ms/churny-iteration of snapshot-rebuild tax at 20k entities across 3 shapes — the receipt for the still-open query-snapshot-rebuild-under-churn row, which this PR deliberately does NOT implement: incrementally-maintained snapshots need lend-bit copy-on-write across every store×shape pair plus a mass-churn fallback to hold snapshot identity byte-identical, a design project with engine-wide blast radius, skipped honestly per its own P verdict).

  • fx particles join the 2D sprite sort band (resx dump 09ad1c9c, #12210). In 2D scene dimensions every sprite draws inside the near-camera sort band (sprite-sort-key.ts) — cutouts write band depth, soft sprites sort by band NDC — while fx particles rasterized at physical play-lane depth and sorted in the alpha bucket by view-depth METERS. Both currencies lose to every band draw, so a background sprite on ANY sortingLayer occluded every particle, and no authoring could put a spell effect in front of a corridor backdrop (fx has no sort vocabulary; creators rebuilt particle effects as sprite entities — Afterschool Starfall's own scripts document the workaround). The fix seats fx in the band: particles rank at layer 0 under the ySort law, half a rank behind sprites so exact-z ties keep the sprite in front. Mechanics: sprites-wgsl VS converts clip z to the band depth of the particle's world z in 2D (new frame uniform sort2d in the _pad0 slot) so the depth test interleaves particles with cutout sprites per pixel; 2D batch sortDepth becomes the band value of the anchor's quantized z rank (emissive bias rescaled to band units) so blend order interleaves with soft sprites and 2D text; band constants export once from sprite-sort-key.ts, and frame loop, scene-view capture, and pixel provenance all stamp the dimension. Receipts: new fx-2d-layering render-harness fixture (2D corridor: particles beat a layer −12 cutout wall AND a soft panel, stay behind a layer +6 prop) — fails both particle probes on the old engine; fx-fusion/fx-soft-distort/effects/part-dissolve/sprites-ysort/emissive-2d all green; unit pins for computeFxSortBandDepth, the sort2d uniform, and 2D batch sortDepth.

  • Distort paint gated by displacement — no distortion ⇒ no paint (the #11475 shockwave law extended to distort sprites, r-156774a3, #12213). A blend:"distort" fragment re-samples the resolved-OPAQUE viewport share — no fx sprites in it — so a zero-offset fragment repainting that share verbatim erased every transparent drawn behind the quad (the kill-explosion transparent-rectangle cutout; a real authored shape — any haze fading its displacement out through 0). The fragment's alpha is now authored alpha × tex mask × smoothstep(0, FX_DISTORT_MIN_PAINT_OFFSET_UV = 0.002, |offset|) — the gate constant is the bespoke shockwave's own strength floor (mix(0.002, 0.05, strength)), so every visibly distorting look keeps full authored alpha while the idle quad paints nothing. Receipts: fx-soft-distort fixture gains the erase-probe pair (an additive glow behind a zero-offset distort quad must match its uncovered mirrored twin), and the effects fixture's rear slash becomes a mechanical target (flat saturated-orange body, whole-frame pixel count, orbit-proof — a repainting shockwave plane collapses the count to ~zero).

  • The player SQL lane — api.sql works on relay rooms, bound to what the game published (#11685). Relay rooms had no durable write lane: api.sql reached a door only a credentialed room host could open, so on the tier that is becoming the only tier the taught persistence recipe silently never ran — hooks fired, saves "succeeded", nothing landed; run_script was dead there for the same reason and migrate() had no seat to run on at all. The lane opens without giving a browser the database: every call a player seat sends is admitted by DIGEST against a manifest kiln publishes from the spec, so a player can only run SQL the game itself contains — never a statement they invented, never one only the game's own privileged seats run — and @caller is a marker the door fills from the relay-verified identity, so a published call cannot be re-aimed at somebody else's row. migrate() runs inside cf-storage in a Worker Loader isolate with an empty env and globalOutbound: null, triggered by the door's own manifest pull for a spec version it has not migrated yet — no seat, no room, no boot: a fresh relay room that only ever contains browsers still gets its schema. Hook posture is decided per hook: the relay fires onPlayerConnected/onPlayerDisconnected in the player's own browser with no token-class gate, while onPlaceStart/onPlaceShutdown run only on the credentialed seat.

  • GPU timestamp envelope — zero-slot guard at the fold + plausibility gate at the latch (r-e03edd0a, dig fc184c35, #12207). iPhone16/iOS26 sentinel units minted gpuFrameMs ~1.5–2.1e6 ms (milliseconds since device boot) because one resolve carried an unwritten query slot — WebGPU resolves those to 0 — and the envelope's min/max fold anchored at the epoch. Two guards: frame-graph.ts timestampEnvelopeMs/exclusivePassTimesMs/seamAttributionMs skip any interval whose begin or end resolved 0n (0 is 'query never written', never t=0 — deletes the unwritten-slot class at the fold), and renderer.ts pollGpuFrameTiming refuses any envelope >1000ms, >4× the wall gap between settled reads, or non-positive (the cross-base variant the zero-guard can't see) — gpuFrameMs holds the last plausible sample instead of relaying uptime. The device-farm sentinel relays the engine number verbatim (correct) and needed no change.

  • Client job pool memory squeeze (squeeze-hunt round 2). The shared-SAB JSON transport is retired: job submits and results now ride structured-clone postMessage both directions, deleting 2.5 MB of resident ring buffers per worker, a triple byte-copy + triple payload pass per message, and the per-submit compatibility pre-walk — measured per-message CPU is a wash (the SAB lane's event-loop wake was a postMessage poke anyway), so nothing gets slower and every message gets one serialization instead of up to six passes. This also makes the ledger-2031 oversize class structurally unrepresentable: postMessage has no channel byte cap, so the 2 MiB-envelope incident shape cannot recur (the clone-fault rejection lane and the 3-attempt requeue bound survive unchanged for the residual fault classes). Separately, the idle teardown now reaps the LAST warm worker too — an explicit reversal of the one-warm floor: an idle world holds zero ~21 MB engine-bundle job isolates resident (was ~23.5 MB carried for the whole session on every iPhone and 1-worker-budget device), while the eager boot spawn still covers the load burst and the first job after a >30s idle gap pays only a parse-only worker respawn.

  • Job-pool poison elimination + server collider-gate honesty (ledger 2031, incident 547f9352). One oversized job envelope (2,110,187 bytes > the 2 MiB shared-channel max) could silently kill ALL client terrain and asset builds: the transport throw landed in the pool's worker-failure handler, which terminated the worker, requeued the same envelope at the queue FRONT, respawned, and resent — an infinite loop (3,077 worker errors in one session) that starved every build behind it until the collider gate force-opened with zero colliders and nothing rendered. Now an envelope that can't ride the transport fails AS A JOB: a submit-time size gate (serialize once — the measured bytes ARE the wire bytes) rejects over-cap envelopes loudly with byte counts, job type, and a payload breakdown naming which arg ballooned; the structured-clone fallback lane (stack-blowing postMessage) takes the same rejection lane; a requeue bound makes ANY envelope that keeps killing workers fail as a job after 3 attempts instead of looping forever; and the worker-side response over-cap falls back to postMessage instead of crashing the worker. Separately, the "server physics is paused… Nothing is wrong — simulation resumes automatically" run_script note is now duration-aware: past a 2-minute warm-up window it names the measured pause duration, says the wait will not resolve itself, and routes to a bug report.

  • Lifetime silence-backstop — the server reaps expired deadlines past a dark-in-fact simulator (r-c8d52362, the 07-30 projectile pile-up, #12211). A wedged client (starved sim worker behind a healthy socket) is Ready, Streaming, unstamped, and uploading nothing; its envelope had no takeover of any kind — not a seat (no re-election), never suspension-stamped (tab visible, socket attached), and the container tier's server seat fallback skips envelopes. A silent successorless-HELD seat deferred the same way. In both shapes api.spawn({ lifetime }) entities outlived their deadline with nobody to reap them — the frozen projectiles piling up in Mecha Blade, and the class the hot drop's own shotfx-reaper.js broom names verbatim. tome/lifetime-reap now resolves a reap LANE per expired deadline: simulator and last-resort stay byte-identical (hooks on), and the new SILENCE BACKSTOP fires on the server when the entity's simulator-in-classification (envelope owner, or the sitting place host) is dark-in-fact — Ready + Streaming + attached, silent on both upload cadences past the health window (the exact isSilent word host retention reads). The backstop destroy is HOOKLESS (the cross-writer destroy PREVIEW's reasoning): a silent client can wake and land its own replay, and envelope deletes are withheld from the owner, so the owner's own deadline reap fires onDestroy exactly once; a simulator that never wakes fires it zero times. Suspension-shaped connections (hidden tab, detach grace) are excluded by the connection-shape gate — a brief alt-tab never moves authority for this reaper either. Red-first pins in the lifetime e2e suite: WEDGED OWNER (envelope, with wake convergence) and SILENT HELD HOST (remainder) — both leaked forever before the lane. The projectiles skill's flagship example now carries the lifetime backstop its own rules prescribe.

  • BREAKING: query results and object summaries hand out the live tracked state proxy (ruled 2026-08-04, supersedes #12092's detached snapshot). buildQueryResult and getObjectSummary (tome/api/query-utils.ts) return .state from the shared per-(world, bag) proxy cache in the new tome/api/tracked-state.ts — the exact object getObject()/getState() return. Reads are 0-alloc off the stored bag (the per-read {...state} spread + read-tracking wrap chain is deleted); writes mark the component updated, replicate, and ride the client-auth intent rail; a retained result observes live state. getMergedTomeState/getMergedTomeStateByIndex (state-utils.ts) and trackClientAuthSnapshotState (intent-context.ts, the detached cross-writer snapshot wrapper + its per-call WeakMap/JSON.stringify path cache) are deleted — the tracked proxy's own traps record reads/keys/has-probes into the ACTIVE invocation at trap time (same dynamic attribution, plus the toJSON-probe basis exclusion the snapshot tracker had).

  • BREAKING at one seam: native structuredClone of a result's .state throws (proxies are not structured-cloneable). Scripts are unaffected — their structuredClone is the unwrapping bridge (script-value-clone.ts; every tracked proxy mint now registers in its proxy registry). The exec result boundary (exec/engine.ts finishOutcome) unwraps registered proxies before the reply crosses, so return api.query(...)/return api.getObject(id) from run_script now arrive as plain data (getObject views previously DEGRADED into a serialization note — fixed by the same unwrap). api.job args gain the same membrane unwrap as the property-write lanes.

  • Tracked-proxy write path (row: tracked-proxy-write-double-materialization): the set trap stores to the TARGET directly and fires the recorder itself, instead of receiver-routed Reflect.set re-entering the defineProperty trap — one quantize walk, one markComponentUpdated, zero descriptor mints per write (was 2 traps + 2 quantizes + 2 marks; object-valued writes paid the whole container copy twice). Recorder emission stays the single funnel, after the local apply. Bench (this box, micro): getState mutate top-level 321→170ns, nested 319→163ns; object-valued assign 277ns single-copy.

  • patchState/patchObjectState/deleteState (row: patchstate-triple-whole-bag-walk): an O(patch) write verdict (statePatchChangesStoredState, the deepMergeState mirror under the column write's own fastDeepEquals law) runs BEFORE the merge — a changes-nothing patch skips the {...current} merge copy, the full-tree sanitize walk, and the full-width equals walk (cross-writer intent emission and the ledger-1157 durable-mirror suppression contract are preserved verbatim; the specValuesEqual persist verdict stays its own law). Changed patches take the new trusted pre-sanitized lane — world.setResolvedByIndexPresanitized / setObjectMapPatch(..., presanitizedChanged) — which skips sanitize (merged bag is wire-safe by induction: sanitized stored bag + membrane-quantized patch values) and the equals gate (verdict already proved the write lands). Raw engine world.set callers keep the full sanitize net. Bench: patchState 2-key vs 50-key bag 1053ns, no-op 193ns, 2-key vs 100-key nested bag 2267ns (new cases).

  • getObjectSummary (row: get-object-summary-eager-materialization) rebuilt on the lazy-accessor literal: id eager; tags/feetPosition/state memoizing accessors — contact/interact/voxel payloads stop paying the whole-state spread + tags copy + position mint when the hook reads only other.id, and land on the SAME final shape as query results. The missing-position {0,0,0} default still skips the physics-quantize check (hadPos capture); present positions keep the read-time pose-driven check.

  • Script-transaction overlay (row: overlay-entity-walk-per-query): currentEntityIds is memoized against the overlay's own membership funnel (spawnEntity/despawn — the base world is frozen for the overlay's lifetime), the O(spawned×N) includes scan is gone, and query-row iteration memoizes on a staged-write epoch (bumped by every setComponentPatch and membership mutation; rows embed references, so in-place read-clone mutation needs no edge). Bench (new case, no pre-change twin): 50 staged spawns + 50 entity enumerations over 3k entities = 272µs total (~5µs per enumeration); the deleted term was O(N + spawned×N + N log N) per enumeration — ≥ 150k includes compares + a 3k sort each, at this shape.

  • Flipped pins, each one the ruling's own semantics: query-lazy-results.test.ts "state stays a detached snapshot: result writes never reach the world" → "state is LIVE: result writes land"; its native-structuredClone parity pin → script-bridge clone + native-throw pin; new pins for door identity (query result / summary / tracked door alias one proxy) and live retention. client-auth-intents.test.ts rewired from the deleted getMergedTomeState door to getObjectSummary(...).state (all basis-recording expectations unchanged — get/has/ownKeys/JSON recording now comes from the tracked proxy's traps); its native structuredClone-of-snapshot assertions flipped to the script bridge. query-fast-path.test.ts reference impl reads the tracked door. New exec pin: returned query results/views cross the wire as plain data (clone-fallback.test.ts).

  • Behavior-preserving invariants pinned by the untouched suites: client-auth e2e + intents (basis order, inc/set classification, revert inverses), script-transaction overlay + tx-spatial query parity, exec snapshot withholding drift pin, determinism pins. Caches name their invalidation edges at the definition site (tracked-state.ts header; overlay memo comments).

  • Module compile artifacts cache across applies, keyed by content and realm flavor (#12098). Every applySpec minted a fresh ModuleContext and recompiled every lib module in the entry graph from scratch — export rewrite, float quantization, ASCII escape, and the factory-memo hash, all O(source bytes), synchronously in the client sim worker on every spec echo (the per-edit freeze on script-heavy worlds; the server measured 2354ms for a 68-script world before its slicer). The behavior/generator caches already survived applies; module codegen did not. The artifact — transformed body + memoized factory — is a pure function of the module's OWN source and realm flavor (require() resolves at execution time through the per-apply context), so it now lives in a cross-apply cache keyed like the failure memo (ref + ui/quantize/audio flavor) with byte-identical source validated per hit. Module INSTANCES stay per-apply — state isolation between applies is unchanged.

  • Pinch zooms the game by default (jacob: "i want pinch to zoom in the game not the site"). The zoomIn axis becomes the engine-defaulted platform zoom channel, exactly like lookX/lookY: when a spec doesn't declare it, the compiler injects the axis and its MouseWheelY binding, which also arms the touch draft's pinch recognizer — so the wheel and the two-finger pinch feed input.axes.zoomIn in every game, declared or not (a declaration of any shape still replaces the default wholesale, and taught camera scripts that read zoomIn keep owning the gesture with their own distance/height semantics — including games where Savi wrote the camera read but forgot the axis declaration, which start working). Games whose cameras never consume the channel get an engine default on touch devices: a presentation-layer zoom the camera system integrates per frame and folds into the published view — perspective rigs zoom optically (fov through the tan half-angle), ortho rigs divide the presented half-height — clamped 0.5×–3×, reset on rig swaps and god edges, standing down whenever anything else claims the gesture (a camera behavior reading zoomIn, any spec axis bound to the wheel, god mode's own god:scroll dolly, or a desktop client, where the wheel keeps its desktop vocabulary). DrawCamera stays authored-pure; the pointer ray follows the presented projection by construction. Containment for the same report (the authored-realm iframe carrying the pinch law so Safari never scales the page) landed separately in #11952.

  • Relay AOI: place-scoped delivery + networking.aoi parity — joins and motion stop being O(room) (#12063). The relay journal gains a place column on heads AND journal rows (idempotent self-heal migration; pre-existing rows read NULL=global) with a producer-supplied retag law (one entity, one place, atomically); joins declare places and drain place-scoped (declare(place) ⇒ drain(place), with a PLACE_DRAIN_ALL backstop); motion rides an EPHEMERAL plc predicate through fanout (fail-open on every axis; interest is socket-scoped, never journaled). Engine side: dial advert + declare flow, keyed-row place stamping with the retag law, drain-bracket admission, receive-all seat pins, and world-sync under scoped delivery (covered-places statements, bounded ghost sweep, placed answer seat, version asymmetry). Parity v2 adds the §13.1 config→lattice resolver on the shared aoi-cells math, emitter motion sharding (≤16 cells + spill), a two-term cell envelope with full-restate declare edges, and the relayBin 4 codec mirror. Net effect: a join downloads its own places instead of the whole room, and per-tick motion fan-out scales with who can see it instead of who is connected.

  • Faster multiplayer boot: the relay join's load-bearing spec snapshot fetch no longer waits for the socket welcome. Every fetch coordinate is already parked at worker mount — before the WebSocket handshake completes — so the engine now kicks the fresh-room latest spec read right there and the join consumes it when the welcome's target matches (fresh-room latest, or a versioned checkpoint pointer whose dbVersion equals the prefetched read's adopted version — immutable content, so age cannot stale it). Any mismatch discards the prefetch silently and fetches exactly as before: same edge route, same 204/failure semantics, same retry ladder, byte-identical fallback. The result is one full edge round trip + spec body download (100KB-5MB) deleted from the serial wsConnected→specApplied boot path — pure overlap with the WS dial and mount chain, worth 150-800ms at mobile RTTs.

  • Relay motion split goes per-COMPONENT, and a journaled name is forgotten without evicting the entity (tucker on staging: "the lag/freezes happen on directional changes", #12197). An avatar's pose and its draw/mixer weights land in ONE change-log row, and the locomotion feature writes the mixer FROM VELOCITY every tick — hold a straight line and the row is pure pose; start, stop, or turn and a non-motion component appears beside it. The lane split ran row.components.every(...), so one journaled component disqualified the whole ROW from the motion lane, and forgetJournaledMotion then ran motionChain.evict(row.id) — dropping the entity from the delta chain entirely; receivers discard rows for an unbound alias FAIL-CLOSED, so the avatar froze at every peer until the re-bind landed, then snapped. Freeze-then-teleport on every direction change. Two narrowings: (1) the split is per component — a row's motion components take the motion lane even when the same tick wrote something that must journal (rows carrying REMOVALS still decline the split and journal whole: the register has no removal list, and splitting a removal from a same-tick re-add would land the pair out of order across two lanes); (2) MotionDeltaAuthorChain.forget(id, names) replaces evict(id) for journaled UPDATES — the journaled component still re-sends in full (the §11.3 rule, preserved at component granularity), but the entity keeps its alias binding (aliases mint append-only within an epoch); evict remains for real deletes. Considered and rejected: reclassifying draw/mixer as motion — DrawMixer carries creator-authored one-shots whose startTick bump IS a replay trigger; that trade swaps a stutter for dropped animations. New pin: relay-motion-delta "THE TURN STUTTER" — a journaled sibling forgets only its own basis, the pose keeps its alias, the receiver discards zero rows.

  • The relay tier's music lane — rail.music works on rooms with no server world (zoo jukebox+stems silent on relay, tucker 08-05, #12202). On the kernel tier TomeMusicState has a single authoritative writer (hooks forward rail.music, the server executes, replicate:"always" fans out); a relay room has no such seat, so the lane re-lands the same three guarantees in relay vocabulary: WRITE — the hook-running client is the room's only executor of its creator JS, so the verb applies to the LOCAL world at relay egress (the command lane's "music" consumer, keeping the Layer-3 claim-window hold: a rolled-back speculation never plays audio); FAN-OUT + LATE JOIN — publishRelayMusicHeadIfChanged ships the local head as one unstamped update row that the relay journals as a keyed head, so live peers converge LWW in the room's total order and catchup replays exactly the latest head to every joiner (the kernel's "land mid-track" story, relay-shaped); CLOCKS — relay clients free-run their tick clocks, so the wire value carries relayAnchor {tick, ms} and ingest rebases every anchor into the receiver's clock (live rows' elapsed ≈ transit, journaled rows' elapsed = dwell — late joiners seek mid-track truthfully, landed fades stay landed, and a stop-fade anchored at a longer-lived writer's tick can no longer hold one peer's music audible forever). An echo guard stamps ingest-applied heads as shared so the publisher never re-ships them.

  • Relay generated content survives place travel — onSpawn defers on unknown authority instead of skipping (the P1 vanishing cars, #12190). The release blocker ("enter a place, come back, the cars are gone; reload and they're still gone"), measured against the relay's own durable state: the generated child's create IS journaled and STILL stands as a live keyed head after the round trip — zero delete rows, no tombstone. The loss was re-derived locally on every world: the re-materialization apply destroyed the authored chassis (remove-diff → destroyEntityWithHook, cascading to spawned descendants) and re-spawned it with onSpawn SKIPPED, because the place was unhosted at that instant (isClientAuthObserverEntity's fail-closed pre-table branch) — and nothing ever re-ran the skipped hook; any joiner that DID fold the still-live create off the journal had it reaped by relay-world-sync's ghost sweep, since the incumbent's liveIds were built from the world that just lost it. The cure at the lifecycle seam: "I don't know who simulates this yet" is a WAIT, not a skip — hosted lifecycle hooks defer until authority resolves, then run exactly once. Measurement pins ride in the two-client harness (journal rows + keyed heads), including the A/B that a HOST-authored destroy does journal a tomb (that lane was healthy — it just never fired here). Named hazard pinned: api.spawn onto an already-live id mints a _2 duplicate rather than adopting, so any lane that re-runs a lifecycle hook must establish the hook's spawns are absent first. The tier pin is a guard: place residency is deliberately not mounted on relay clients — placeCleanupSystem is mode:"server", so no place is ever unloaded in a relay room, and mounting residency there without local-only teardown first would turn memory eviction into permanent room-wide data loss (the docstring that primed the misdiagnosis — "the place can re-materialize on re-entry" — is corrected in place: it only ever described the spec's skeleton).

  • Relay presence on the wire, cross-place removal, and one ordered egress lane — 8 defects across 2 dumps (#12185). The headline cure: the renderer's visible set was EDGE-triggered (SPAWN admits, DESPAWN evicts, every other op gates on set membership alone), while the include predicate (ecs-sync computeShouldIncludeEntity) was already place-aware and correct — so a peer that left or entered the viewer's place flipped the verdict with no lifecycle row of its own, and the client computed the right answer every tick and never applied it, in both directions. writeIncludeReconcile now level-triggers the set: it takes the entities whose verdict inputs changed in this tick's drained op window and repairs any disagreement between verdict and membership — despawn what is in and should not be, snapshot in what should be and is not; cost is strictly O(membership mutations this tick), and the nomination list rides the op walk that already retires memo entries. The render-suppression grace (#735's snapshot grace) is scoped and made a level for the same reason. The remaining defects in the set land presence rows on the wire and collapse egress onto one ordered lane so cross-lane reordering can't resurrect a removed peer.

  • Relay wire perf ladder — binary keyed lane, drain compression, quantization (dark), command classification, client parse-once (#12046). B1: the keyed-STATE lane goes binary (BIN_OP_KEYED_BATCH + single-part register rows) behind the invite's accepts lattice with a two-step degrade latch and the place-bit reserved for AOI — measured 3.4–5.3× on the keyed tick. B2: catchup/retransmit/joinBuffer drains compress (BIN_OP_BATCH_DEFLATE at relayBin 3; ceiling + compaction-hold + drain-serialization replace the sync-drain accident; the live wire stays plain forever). B3: a compile-closed relay command-lane table kills the zero-consumer journal class — 12 leak names stop journaling. B5: motion quantization Q1 (op-10 register, quantize-at-source) lands default OFF, feel-gated on the walk; rest stamps bit-equal live values by construction — measured 1.3–1.6× over the binary lane per frame, 19.7× vs JSON on the seeded trace. B4: client download-path CPU — a parse-once memo door (fold + apply share one decode, ack facts extracted at emission), host-tick register adoption (golden vector pinned three-way), and the world-sync round-trip kill (pass-through translate + stringify-once assemblers). Review fix riding along: beginRelayInflate's async task is guarded — a throw from a session hook mid-drain no longer strands the ingest-busy latch forever (the silence watchdog provably could not heal it: activity stamps before parsing).

  • Server terrain chunk-build submissions are budgeted per tick (#12097). Server chunk builds execute inline in the engine isolate's realm (the inline job executor — no worker threads), so a full-place sweep that submitted its whole pending budget in one tick was tens of seconds of back-to-back build macrotasks on the 2vCPU prod container. The tick RPC blew its 10s deadline behind that batch, the shell reloaded the isolate as wedged, and the reboot from the DB spec erased runtime-spawned entities — a spec-write storm turned into "the room restarted and the zoo is gone". The sweep gate (stale-cancel + trailing-edge coalescing, 5.2.15) already kept saves from stacking sweeps; this adds the missing work-side half: at most SERVER_MAX_CHUNK_BUILD_SUBMITS_PER_TICK (4) non-safety-lane builds submit per tick, so the tick loop keeps breathing between builds and the wedge-reload-amnesia chain can't fire from terrain load alone. Safety-lane builds (player support) keep their priority lane.

  • Perf: flyweight set-event dispatch for motion-class SoA components (speed-of-light row soa-dispatch-flyweight, ruled 2026-08-04). A new SoA-only schema opt-in transientSetEvents routes setSoA3/setSoA4 subscriber dispatch through one pooled event object (+ pooled previous/value vecs) per store instead of minting three fresh objects per dispatching write — the top steady-state GC feeder at 3k-moving-entity scale (~270–500k objects/s → ~0).

  • BREAKING (engine-internal subscriber contract only): for the seven opted-in components — transform/world-feet-position, transform/world-rotation, transform/world-scale, physics/body-position, tome/local-feet-position, tome/local-rotation, tome/local-scale — an onComponentSet event object retained past the callback is overwritten in place by the component's next dispatch. Every in-tree subscriber of these components was audited (spatial-index, hierarchy-solve, hierarchy-render-solve, local-transform-projection, rapier/sync, aoi-index, sprite-raycast, perception, nav-grid, overlap-probe-cache) and reads fields synchronously; onComponentSet is not reachable from creator scripts or any run_script/ObjectAPI surface, so no session-facing behavior changes and no banked-claim falsification exists for this change. Re-entrant same-component writes fall back to fresh mints; dev-mode mutation tracing forces fresh mints throughout. Contract: src/engine/ecs/contract.md (Subscriptions), pins: src/engine/ecs/__tests__/soa-dispatch-flyweight.test.ts. No pre-existing test pinned the old fresh-object identity for these components, so no pins were flipped — the new pins are additions.

  • Spawn live through the deferred-compile window (#12096). On the container tier a spec apply defers the whole-graph behavior compile off the tick path (perf-apex L18): the old graph keeps serving while the new one builds in slices. A row spawning during that window had its compiled entry only in the graph still building, so it appeared fully rendered but inert — no onSpawn, no update — until the swap: seconds on script-heavy worlds. Fix at the spawn path, with the pattern spawnStampedPlayerBodyRows already uses: when the serving graph has no entry for a spawning row's behavior refs, compile just those refs (behavior-cache-assisted, typically 1–2 refs) into the serving graph before spawnObject, so the row boots live like every other spawn.

  • 2D sprite silhouettes stop ringing in the sky color (zoo relay walkthrough 2026-08-05, #12199). #11597 routed the depth-writing sprite cutout class (the 2D alpha default) through the plain cutout slot; sprites register BEFORE tilemaps, so every silhouette edge texel (alpha in [cutoff, 1)) blended against the SKY still in the framebuffer, and its depth write locked the stale blend in as a ring the tilemap ground could never repaint — orange on Farm Dusk, blue on Village Green, on ALL sprites. Cure: MainPassDraws.cutoutBlend — blending cutouts get their own fold slot drawn after EVERY contributor's plain cutout, still inside the depth/velocity-owning opaque half (#11597's routing intent — AO, viewport sharing, velocity all unchanged); far-to-near within the lane keeps sprite-over-sprite edges correct. Receipt: new sprite-2d-cutout-edges pixel fixture (magenta-sky leak detector: 1148 leak pixels on the broken routing, 0 with the fix; authored draw/outline sprites still ring their authored cyan). Savi-latency cluster (squeeze-hunt round 2 — the apply path's second act after the changed-set-first visit filter): a non-short-circuited applySpec re-derived spline/room lowering for the WHOLE world on BOTH diff sides, re-ran ~6 whole-spec derive/library sync walks ungated, rebuilt every per-apply container, and every attributed ObjectAPI/fold spec write copied the ENTIRE object-diff-signature map to evict a handful of keys.

  • Per-row lowering memo (src/tome/lowering-memo.ts): generative-spline (road/powerline/stairs/coaster/preset/fence/hedge/pipe) and room rows reuse their lowering products by reference when the source row (by identity), place mode, installed terrain definition token, and the composed terrain:height chunk-version fold over the entry's padded bounds are all unchanged. The field token is the SAME fold the spline redrape watcher re-lofts on (shared helper), so "the ground moved" can never mean two things. Scripted-kind splines, generator-script-bearing rows, scatter rows, and lookAt rotations are excluded and re-lower fresh every apply, exactly as before. Eviction edges: identity replacement (ObjectAPI/COW-fold writers), the spline drag preview's sanctioned in-place row write (explicit evict beside the diff-signature one), terrain def/field tokens, per-apply pruning to the final new-side expansion, whole-memo drop on a failed apply.

  • Old-side expansion reuse (TomeAppliedGeneration.expansion): under the delta-0 gate's own completed-apply binding (specRef === live doc) plus expansion-time keys (residency, place-instance content, player-body derivation inputs — a new computePlayerBodyDerivationKey covering active players and session-parent liveness — and the unfiltered-record identity), the next apply's old diff side reuses the previous apply's FINAL new-side expansion whole, containers included (flat list, byId, placeById, id set). Omitted whenever a survived-destroy retry row diverged the recorded doc from what was expanded. Old-side lowering failures stop double-reporting (the recording apply's new side already reported them once).

  • Caller-mutation guard: everything recorded past an apply (memo source rows, expansion defs) is re-bound to the ENGINE-owned clonedSpec row twins — "row identity ⇒ content unchanged" holds for immutable-by-replacement engine docs, not for a caller's own doc mutated in place and re-applied (the pinned in-place scatter-count editor pattern stays a full visit).

  • Diff-signature eviction side index (object-diff-signature-invalidation.ts + TomeObjectDiffSignatureIndexResource): a root-id → key bucket index built once per apply beside the signatures map makes attributed spec-write eviction O(touched roots) with in-place deletes, replacing the per-write whole-map copy + full key scan. Unbound index (any non-apply map identity) falls back to the historical O(N) scan-into-fresh-map path.

  • Derive-pass skip gate (TomeDeriveSyncInputsResource): the six library syncs (materials, field-feed demand, authored clips, looks, textures, warm hints), the spec-shape warn rail, and the asset-manifest merge are skipped when every raw subtree they read (scripts/places/assets/camera/player identities) plus the row-derivation keys match the last completed apply — with per-pass derived-component presence checks so projection resets (which recreate the tome/spec entity bare) always re-derive. Runtime ensure-hooks, the renderer join-watchdog force lane, and TomeSpec reset hooks call the sync functions directly and never see the gate.

  • Container floor: buildObjectDiffSignatures returns the previous map by identity on a 100% hit (consumers pair every signature lookup with an oldObjectById lookup, so a superset map is safe); the reused old side ships its containers from the generation record.

Bench (spec-fold-cost.test.ts, new apply-path case: 1,060 source objects — 20 roads + 10 powerlines + 10 fences + 20 rooms + 1,000 flat — through the fold lane): delta-1 apply median 51.5ms → 12.4ms (expand-old 15.3→0.4ms, expand-new 14.8→0.4ms, signatures-new 15.2→2.6ms); 200-write burst 439µs → 112µs per write. Anchor (ecs/soa read-ts-plain) 13,107ns.

Named residues, deliberately not built here: the compiler's second child re-expansion per apply (compileSpec runs before expansion — reordering is its own change); pooling of genuinely apply-local scratch (small, and reentrancy-sensitive); the rebind-candidate query walk. Resident-memory note: the generation record now retains one expansion's containers and the memo retains lowering products between applies — bounded by the currently-expanded world, released on prune/replacement.

  • CPU particle + audio lane squeeze (perf, behavior-preserving — squeeze-hunt round 2). CPU-owned particle effects (ribbons, trails, multi-sink, no-WebGPU fallback) drop their dominant GC feeds: the fx closure compiler evaluates every field expression into per-node scratch tuples instead of minting a fresh array per vec3 node per particle per frame (bit-exact f64 math and identical rng draw order, pinned by a master-generated golden digest); the reap pass mark-then-compacts instead of splice-per-death (O(alive) per frame instead of O(alive × deaths), death-record and draw order unchanged); snapshot group writes resolve their draw group through per-sink pointer caches instead of building a texture:blend:mask:align:soft key string per particle; and ribbon trail rings mutate their evicted slot in place and recycle whole rings through a capped per-size pool. Bench (5k churny particles, one frame = simulate+snapshot): churn 1.22ms → 0.69ms, 5k-sprite pack 0.59ms → 0.26ms, 2k-ribbon 2.07ms → 1.55ms per frame. Audio lane: the vibe transport tick no longer builds every pattern tree twice on the authority (primeBpm survives only on first-tick/pause/resume edges; the steady-state BPM check reads the tick's own evaluation — one pinned consequence: a mid-flight setBpm change now re-anchors on the same tick but its new tempo reaches the trigger-window math one tick (~33ms) later; the audible renderer schedules on its own lookahead either way), and skips the per-tick full-source hash behind a string-identity gate (3-vibe transport tick: 16.1µs → 9.0µs). The audio deriver stops rebuilding and comparing every emitter value every render frame — AudioIntent add/set/remove hooks and TomeSpec edges feed a dirty set (vibes waiting on script replication retry on spec arrival, not per frame). Audio prep collects reverb zones once per frame instead of re-walking every zone per emitter (O(emitters+zones), same verdicts), and its per-frame policy/set scratch is pooled.

  • GLB texture payloads stop pinning whole files (jetsam cluster, round 2). parseGlb minted texture bytes as views over the GLB input buffer, so any retained parse pinned 100% of the file — geometry, JSON, and texture payload — for the retainer's lifetime. Three cuts along one seam: (1) GLB-embedded image bytes are now OWNED exact-size copies, so the fetch buffer collects after parse (renderer-side retained drops from whole-file to texture-payload-only; the budget estimator's texture term becomes honest by construction); (2) the sim asset service parses with retainTextureBytes: false — the sim worker never decodes or uploads textures (reader census: zero sim-side consumers), so the texture share (often 50–80% of a textured GLB) never becomes resident in the worker-lifetime modelsById cache at all; (3) the renderer models registry releases parsed.textures[].bytes once every decode for the asset settles — the GPU copy is the source of truth, device loss is a terminal reload wall (recovery re-fetches + re-parses), and assets are created once per modelId per scene, so no in-session reader remains. Receipt (8MB-texture synthetic GLB, reachable-backing-buffer arithmetic): old = whole file pinned per retained parse; owned-copy = texture payload only, input unpinned; sim flag = ~0; post-settle release = ~0.

  • Render-channel writer scratch sheds its burst high-water. The spill-encode scratch doubles toward MAX_ARENA_BYTES (512MiB) during boot/backlog bursts and was never re-minted smaller — the high-water stayed resident for the session (the transport-residency stack named in ecs-sync's collapse-gate comment; PR #12099 iPhone-16 jetsam kills). It now re-mints at its 256KB rest size after ~30s of active sim ticks with zero spill demand and an empty pending backlog (frames never alias scratch — pushScratchFrame copies). Re-materialization is the existing cold-path shape: the next spill's ensureScratch(16MiB). New getScratchCapacityBytes() observability getter.

  • Preload-hint derivation survives metadata-only spec landings. collectSpecPreloadHints memoized on whole-spec identity, so every asset-metadata landing (bounds batches, the per-analyzed-model triangle stamp, socket/parts rails — dozens per boot, plus every landing mid-session) paid ~5–7 regex passes over the entire script corpus plus place/object walks to re-derive an identical answer. The four metadata writers now land through one shared helper that forwards the memo when every derivation input identity is preserved (input set positively bounded: collectSpecAssetEntries reads places/terrain/atmosphere/objects/player/scripts and never spec.assets; sprite-warm-variants reads scripts/places/objects/player) — a hit returns the IDENTICAL array, so the fast path's previousRefs delta stays a no-op.

  • Sidecar host-derive rails stop walking the world. Both rails' ~1s scans iterated every TomeParent-bearing entity (the whole parented population) on every metadata-authority client — including all singleplayer sessions — even with zero socket/part attachments. A hook-fed per-world attachment index (TomeParent add/set + one-time seed; the BoundsPrefetchFeature needs-set shape) makes the scan O(attached-children), i.e. zero for the overwhelming majority of games; stale ids prune on visit. Riders: the per-tick completed.splice(0) empty-array mint is gated on length; the authority resolution (place-hosts read + session query walk) is skipped entirely on ticks with nothing to scan or dispatch; tombstoned entries now cost two boolean checks per tick instead of nested spec probes (resolved-entry cleanup — including a tombstone's cap seat — rides the scan-cadence sweep, with a dispatch-due re-check so a resolved pair is never fetched).

  • Perf: squeeze round-2 jetsam cluster (half A) — budgets and eviction for decoded media, behavior-preserving (sol-table .tmp/squeeze-hunt-2026-08-04/round2-rows.json). Decoded audio PCM gets the aggregate budget MAX_BUFFERED_CLIP_BYTES never was: the resource service keeps a raw-decoded-bytes ledger in LRU-touch order and, when a decode crosses the per-device budget (AUDIO_DECODED_PCM_BUDGET_BYTES in perf-static-data.ts — 128/64/48 MiB by the texture/model keep-alive facts, the table's third instance), releases oldest-touched buffers first, pinning the WebAudio renderer's live voices + pending starts (and the just-decoded clip, so one over-budget clip can never decode-loop). Only handle.buffer drops — handles, stream elements, duration memory, and failure/park budgets survive, so an evicted clip re-decodes on demand through the exact paths that loaded it; the re-decode latency on a cold retrigger is the traded term. getStats().decodedPcmBytes is the gauge. The video warm cache's flat 64 MiB budget becomes device-tiered through BOTH construction sites (the shared main-thread cache and the tome-UI frame warm lane): VIDEO_WARM_BUDGET_BYTES 64/32/16 MiB — a mobile miss is the module's own documented designed degradation (plain JIT streaming). Renderer-side: runModelEviction gains an exact O(1) early-out (running totalCachedModelBytes maintained at the cache's set/delete/clear seams; unreferenced ⊆ cached, so under-budget frames skip the per-frame walk + sort + array mint — getModelEvictionStats().cachedModelBytes pins drift). fx particle rasters stop being a session cache: they release by poll recency (every consumer — pack compositor, content-scale measurement — polls per frame until it settles, so 5s of silence IS the settled signal; "unavailable" tombstones stay as the refetch-storm guard), and a composited pack drops its captured per-layer CPU rasters instead of pinning them for the pack's lifetime. PipelineCache gains pipeline-key retention: material instances retain the variant keys they mint and release them on destroy, so an edited-away scripted material's driver-compiled PSOs leave the cache with the record (scripted-lane idle-LRU eviction, tilemap/decoration scripted retire, booth cleanup — all one seam) instead of living to device loss; drops bump generation so draw-memo pipeline-handle memos re-request (a map hit for survivors), a booth previewing a live material holds its own retain, and un-retained keys (fixed shaders, post chain, lanes) keep session lifetime exactly.

  • Named residuals: the audio ledger counts raw PCM only — a looping clip's conditioned copy rides the raw buffer's lifetime and roughly doubles its true cost (the budget is deliberately conservative against it). A raster whose pack died mid-fetch stays resident for one grace window before the sweep drops it.

  • Perf: speed-of-light squeeze wave 3, tick spine + timers (sol-table .tmp/squeeze-hunt-2026-08-04/2026-08-04-v2-whole-sim.md). The client runtime finishes the server's sharedCtx shape: per-system SystemContext objects, state closures, and push/pop run closures are cached at rebuildPhaseLists (tick/dt/jobs re-stamped per run), ctx.rng becomes a per-run lazy getter deriving the identical (epoch, tick, phase, name) fnv seed on first read, per-phase reduction stagers are prebuilt like the server's stagerByPhase (killing the per-phase flatMap and its reductions ?? [] empty-array mints), and Diagnostics.runWrapped writes a pooled verdict object — the ~6-7-alloc-per-system-run mint chain drops to ~0 and the 120-no-op-system spine bench (runtime/client-spine, new) goes 146.8µs → ~11µs/tick as measured during the hunt (loaded box); the #12166 adversarial re-derivation measured the identical paired bench at merge-base vs head on a quiet box as 79.2µs → 21.4µs (~3.7x) — the win is real, the headline ratio is measurement-conditioned. LastReductionStager memoizes its Proxy + boundFnCache (stable world identity for reduction-declaring systems across ticks — pinned by reduction.test.ts) and takes a prebuilt Set for the reducible-membership probe. Timer dispatch drains a (dueTick, id) binary-heap index (tome/timer-heap.ts; armTomeTimer is the one arm gate) instead of scanning every armed timer per tick — cancels/reaps stay map-only deletes dropped lazily at pop, holds/spills re-queue under their original key, fire order stays byte-identical ((dueTick, id) pins added to timer-dispatch-budget.test.ts), and the 5k-armed idle-tick scan collapses 6.1µs (reconstructed-old) → ~70ns (~90x; new bench case) — the #12166 re-derivation measured the real old scan at 88.8µs on the merge-base tree, so the paired ratio is ~897x, an order better than the reconstruction suggested. Small kills ride along: the server's per-tick sharedCtx/noop-jobs/placeholder-rng mints hoist to module/runtime scope, sim attribution keys per-kind maps by raw name (no per-sample NUL-template string), the client sim-step timing window becomes parallel Float64 rings (no per-tick sample objects, no Array.shift), and the worker's controlled-entity change probe compares retained expanded lists via scratch arrays instead of minting fingerprint strings per frame.

  • Named residuals: a dead owner's timer now reaps at its dueTick (dispatch's hasEntity gate) instead of eagerly per tick — closure retention until due is bounded, and the spill breadcrumb's armedTotal counts such timers until then (log payload only). Reduction proxies are stable across ticks by design — the identity-per-tick pin in reduction.test.ts flipped to a stability pin. Static-body rotation ordering (ledger 2028, savi filing 61538a0c / Streetwork): under replication an entity's components arrive as individual rows, and chunked bursts + flood-budget re-delivery put tick boundaries anywhere in the row set — so a static body can materialize from its physics/body-config row before its transform/world-rotation row arrives, born wearing identity. The dig's churn repro (adopted here) shows current master converges those orderings, but only through a stack of four mutually-covering accidents (the write-back's cache-unchanged guard, the #550 transform-replace compare, the state re-align before dispose, dispose clearing caches). This change replaces the accident stack with an invariant: a static body's rotation is a pure function of its replicated WorldRotation — physics never authors it, and every (re)materialization derives from it.

  • initializeBodyState: statics read WorldRotation (the author row) before PhysicsBodyState.rotation (the physics echo) — the previous precedence rebuilt statics at whatever stale orientation the last write-back caught unless the caller remembered to re-align state first. Dynamic/kinematic precedence unchanged (physics owns their rotation; state stays first). Engine-managed statics with no WorldRotation (terrain chunks, tilemap colliders) keep their state-seeded behavior.

  • applyStateToComponents: statics no longer write WorldRotation back to ECS. The write-back used to mint WorldRotation=identity onto a mid-adoption static, occupying the author's replicated slot with an echo — upload fabric for ownership write-fights on client-auth hosts, and one component-sourced cache seed away from permanently blinding the transform-replace compare. The rotation cache still records the realized body pose (it is exactly the "realized" side staticTransformReplaceNeeded compares the author row against).

  • getOrCreateHandle (signature-change swap): a static whose colliders are about to be re-created first gets its body pose re-written from the author components (WorldRotation, feet-first translation), so the fresh colliders index at the true pose — the swap used to inherit whatever pose the body was born with, so a signature row (scale, config re-delivery) co-arriving with a late rotation re-indexed fresh colliders at the wrong orientation and re-marked the signature fresh, permanently. The swap itself deliberately stays in place (no dispose+recreate: a removed-and-reborn body is not query-visible until the next step — a one-tick hole for raycasts and CC grow probes — and terrain edits pin handle identity); character capsule-grow holds and the mesh-not-ready hold are untouched.

  • Static birth pose is author-derived end to end: the body desc and initializeBodyState both resolve a static's translation feet-first (WorldFeetPosition is the authored surface; BodyPosition is the realization echo, stale across a component-written move) — previously a static rebuilt after a component feet-move could be born at the stale center whenever the slept-pose coherence check didn't fire, and its colliders spent the rest of the tick indexed at the birth pose.

  • seedPhysicsCachesFromECS: never seeds a static's rotation cache from ECS components (that is the one writer that could make cache == component while the body wears something else, silencing the compare forever). Currently test-only; guarded so it stays safe if resurrected.

  • Suite: dig2028-rotated-static-box.test.ts — the dig's two straight-path probes (drive across the world AABB unblocked / into the oriented face blocked), the staged churn repro (config-first adoption → reap → re-adopt, rapier cuboid halfExtents + body rotation inspected at every stage), and the signature-co-arrival poisoning case. The no-phantom-mint assertions are red on master before this change.

Named residues, deliberately not built here: (a) the synthetic feet-delta contact.normal savi burned probe cycles on — already eliminated by the fabricated-contact fix (ledger 2022, #12153, landed); (b) the owned-entities write-fight (~38k fenced rows in the filing room — netcode/command-dispatch vs the hosting client over tome/owned-entities) — the environment that tears adoptions mid-entity and can fence the rotation row out entirely; rows 2016/2017 ownership family. When the rotation row never lands in ECS, no physics-layer fix can dress the body correctly — this change guarantees the body converges to whatever the author row says the moment it exists, and that physics never fights the author for the slot.

  • Live terrain-kind flips compile — geometry inheritance is same-kind only (incident 6ac4bd05, #12209). A live tilemap→heightmap flip could NEVER land: the heightmap config build inherited existing.definition.verticalRange from whatever definition was installed, the tilemap def pins [0, 0], and validateTerrainConfig throws 'verticalRange min must be less than max' — on every apply, forever, until a room recycle cleared existing. The spec said heightmap while the world kept the stale tilemap ground; clients painted the old terrain over the hole (a wisp flip-looped on it for minutes on Fire Nuke Island — 9 server + 105 client definition_failed rows; a second app hit the same wall the same night). The voxel branch had the silent twin: same cross-kind inheritance, no validator throw — a [0, 0] voxel world with no vertical space installed quietly. The rule, already applied at the heightmap lodRanges site, now covers every geometry field in both branches: verticalRange/origin/lodRanges inherit only from a definition of the SAME kind; signature/revision bookkeeping still reads any installed def. A kind change compiles exactly like a cold boot ([-32, 256] default); same-kind inheritance is unchanged (pinned). Loudness (the incident's third ask): a definition that fails to install now names the place and the consequence in the creator-facing runtime log — 'Terrain for place "main" failed to install (…). The place keeps its previously installed ground — or none — until the terrain config is fixed.' — and tome.terrain.definition_failed carries placeId. Red-first pin: terrain-kind-flip-live.test.ts (flip installs + objects survive, voxel non-degenerate, same-kind inheritance preserved, failure names the place).
  • Wire-codec squeeze (round-2 hunt): the client netcode meter and the room wire codec stop doing hidden per-message work. The debug transport used to serialize every outbound JSON message THREE times (once for the wire, once for the byte count, once again per bucket for the breakdown) and mint a payload-sized byte array per measure — byte accounting now rides the wire's own serialization (kernel lane: zero extra passes; relay/hold lanes: one counting measure), and breakdown detail (out-lane section splits, in-lane per-packet decode breakdowns and their 15s retained object graphs) is built only while the F8 multiplayer panel is showing it. DD bandwidth totals are byte-identical, armed or not. Codec inner loops shed their per-write mints: PacketWriter.writeUtf8 writes ASCII (entity ids, keys, component names — ~all wire strings) straight into the packet buffer instead of encoding a fresh Uint8Array per string, and the generic object/object-map writers enumerate keys once instead of minting entries+pair+filtered arrays per node — wire bytes byte-identical, pinned by round-trip and reference-encode tests. The server room dictionary's entity-alias map — the room's only unbounded per-entity residency (grew forever under spawn/despawn churn) — now evicts despawned rows once the replication change-log prune proves no future drain can reference them (two-phase mark + prune watermark; revival cancels; aliases stay monotonic).

Engine v5.2.15

Released August 4, 2026

  • You can now put the game's "ear" anywhere — pin it to a ring-side mic, a security camera, or a moving NPC, and all spatial sound pans and fades from there instead of your camera. One place setting, and clearing it puts the ear right back.
  • Statue false alarms gone: 3D models whose textures already ride our optimized format no longer trigger the false "content may be missing or wrong" warning. (Models with webp-only textures still render untextured by design — re-generating the texture is the road.)
  • Savi can now see your game's HTML UI the way she sees your 3D scene: ask "what's on the screen UI right now?" and she gets the real list — every named element and button with its position, size, and whether it's actually visible — and "what UI is at that spot?" answers with the exact element stack, including an invisible overlay eating your clicks. No more guessing whether your menu rendered.
  • AI characters walk properly. NPCs now truthfully report standing-on-ground and their real speed, so animation logic that checks those no longer traps a walking character in a looping fall pose — and scripts can read an NPC's actual velocity instead of guessing from position changes.
  • Games boot a little lighter — the simulation engine dropped nearly half a megabyte of renderer code it never ran.
  • Terrain textures now tile cleanly by default — the faint ghosted seams that could trace the texture grid are gone. If a big natural surface reads as an obvious repeating stamp from a distance, ask Savi to turn on anti-tiling for that one material (antiTiling: "stochastic") and the repeats decorrelate.
  • Terrain edits during busy builds no longer stutter the room. When terrain changes land in a burst — like Savi reshaping a world while you play — the engine stops rebuilding soon-to-be-replaced ground over and over. A single terrain edit applies just as fast as before.
  • Paintings keep their colour! Putting a soft or near-white color on a textured object no longer turns the art black-and-white — the texture shows its own palette. Strong colored tints still repaint the hue like always. One trade: a muted mid-tone color on a textured object now darkens the art instead of repainting it — if textured surfaces look darker after this update, brighten their colors or ask Savi to re-tint them.
  • Landing on a platform's corner no longer locks your jump — corners that hold your weight count as ground.
  • Editing a script no longer counts its creations as casualties — death effects only fire for real deaths.

technical notes

  • PlaceDef.listenerEntity — the spatial-audio ear is now a spec property. The Web Audio listener pose derives from the named entity's live transform (position + rotation) instead of the camera; absent = today's defaults exactly (camera; 2D modes: the controlled character on the play plane). Set/clear live through updatePlace(placeId, { listenerEntity: "ring" | null }) — no new verbs. A dangling id falls back to the default ear rather than going silent; 2D places keep their fixed mode-derived stereo bases (sprite facing flips still can't invert left/right), with the bound entity supplying position; instance places resolve template-authored ids through the same <placeId>:<bareId> rename their objects get. Resolution lives in audio/listener-follow (client), pinned by listener-follow.test.ts.
  • GLB parser accepts the EXT_texture_webp DECLARATION — and deliberately never resolves its source (#11992, ledger 1969; resolution half trimmed same-day per jure's ruling, thread 1785781497: "we don't want non-ktx2 textures going into the engine, ever"). The extension joins SUPPORTED_EXTENSIONS, so the required extension "EXT_texture_webp" is not supported — content may be missing or wrong warning class dies: ~40 per dump on moodboard GLBs whose declaration is stale cook-era metadata while every texture actually rides KTX2 (two savi-filed engine-bug incidents were this false alarm). parseTextures does NOT read extensions.EXT_texture_webp.source: a webp-ONLY texture (extension reference with no top-level source) stays unresolved — the model renders untextured, and the honest texture[N]: no image source warning stands — until the asset is recooked to KTX2 (the ledger-1198 recook lane, recook-webp-avatars.ts). Textures riding KHR_texture_basisu or a top-level source resolve exactly as before.
  • Identify UI-plane mode — the scene-identify verb's named sibling (r-3934ca4b). identify_object gains ui: true: the same read-only query-primitive bar aimed at the AUTHORED UI plane (the creator's HUD in its sandboxed realm iframe — the plane host-page reads and 3D lanes structurally cannot see; ravi's badgerblunts receipts cec6b199 §3 class B′). Alone it enumerates the rendered census — elements that are addressable (id), take input (the pointer-events law's INTERACTIVE_SELECTORS, the exact grant vocabulary), are world-anchored, or are top-level overlay roots — and with screen: [x, y] (0..1 viewport fractions, the identify family's convention) it answers "what UI is at this point", latest-in-document (painted-on-top) first, style-hidden click-catchers included and marked. Each match: id + kind (tag) + screen-space rect (CSS px + the frame viewport) + visibility + interactive + a short label. Resolution rides the realm's own rails end to end — two new read-family query verbs (elements | hit, strictly decoded like the rest of the closed vocabulary) executed by the frame's own read executor against the tree it renders; never a DOM-scrape parallel, and platform chrome (Spawn's rail) lives outside the realm, invisible here by design. An empty plane is an HONEST EMPTY: ok with matchCount 0 plus a note derived from the HUD-pipeline receipts naming whether nothing was ever rendered or the render just emptied. Zero new spec words, zero ObjectAPI surface — an inspection verb, not an authoring one.
  • Platform-side (kiln magic-cdn — rides platform deploys, not this kernel build): the write-once refusal metric learns its outcome (#11991, ledger 1967). spawn.v3.magic_cdn.write.overwrite_refused fired once at the top of refuseOverwrite with {component, layer} only, so a benign crash-resume spike and a cook-killing identity-error spike were indistinguishable without a log dig. The single emission now lands at the three consumption terminals tagged outcome: declared_handled (caller declared handlesRefusedOverwrite), resumed_identical (existing bytes verified byte-identical — crash-resume, benign), identity_error (existing bytes are not this run's bytes — the cook fails loudly, the alert-worthy slice). Exactly one emission per refusal, so totals across outcomes match the pre-tag count and existing monitor sums are unchanged.
  • isGrounded() and getVelocity()/getObjectVelocity() truthful for NPC agents (#11994, ledger 1970). Both verbs read only physics-body state (PhysicsCharacterControllerState / rigid bodies) that agent-moved NPCs never have, so isGrounded() returned a plausible false forever on every npc: {} entity (air-state machines tripped into fall on tick 1 and looped the fall clip against the walk cycle — enfeul's Spartans) and getVelocity() read silent zeros. The agent mover's own NpcMotion (grounded + achieved velocity, engine-computed by npc-move-resolve every tick) now answers: character-controller state first (players byte-identical), then the agent lane — and the agent lane answers ONLY where the mover is the system of record (npc cfg/intent present, not controller-owned, not force-driven dynamic; a stale motion row can never shadow live physics). Velocity reads ride the same 0.05 read grid as every other velocity read. Stale jsdoc corrected at the source (types.ts → api-reference/TomeAPI regen) plus one npc.md line.
  • Sim worker sheds the renderer (#11990). One value re-export of createLumeAdapter in engine/renderer/index.ts dragged 253 lume modules (frustum-cull, primitive lanes, lighting, sky, decals, shade graph…) into the sim worker and main thread — realms that render nothing. The re-export is deleted (the type-only export stays, erased at build): sim runtime-worker −0.373 MiB, main index −0.035 MiB, renderer unchanged — −0.408 MiB total off the minified kernel bundles.
  • Terrain stochastic anti-tiling becomes a per-material enum, default off (jacob's ruling, thread 1785760739). New spec knob antiTiling?: "none" | "stochastic" on terrain materials (default "none"). The enum rides the shading chain (interpreter → buildMaterialPack colorBy → render config → lume atlas params PARAM_ROW_PBR2 .y as TERRAIN_ANTI_TILING_MODE) and gates the jitter strength per LAYER in the heightmap surface shader — mode 0 collapses to the pinned bit-exact plain sample, so default terrain tiles seamlessly; "stochastic" is the unchanged #9338 per-meter jitter mechanism. The string enum is deliberately the general form: hex tiling (jure's branch) lands as a third accepted value and mode index, never a new spec field or params row. Shading-only field: toggling it takes the materials fast path (no chunk rebuild). Device gate (verify-terrain-jitter) grew default-clean and per-layer-independence scenarios plus before/after captures of the repro class.
  • Server terrain full-place sweeps: stale-cancel + save-burst coalescing (#11995, ledger 1965 cut 1). Every terrain-content save re-fired a FULL server rebuild sweep (256 chunk builds — 30–100s on the 2vCPU prod container), and during wisp world-build bursts the sweeps STACKED: each save saturated the pending budget with builds of dead content while the previous sweep drained (the measured tick collapse, 14 ticks/71.8s). Two scheduling moves at the seams, content and determinism untouched: the interpreter's terrain-content trigger now carries supersedeInFlight — every pending build for the place cancels before its body runs and the place-wide marks resubmit under current inputs the same tick (no warn, no backoff) — and a re-sweep landing within 10 ticks (~333ms) of the last sweep start holds until the burst settles (trailing edge, 90-tick max hold; held sweeps park as persisted dirty marks). First save of a burst fires immediately, so single interactive saves keep today's latency exactly. Player-support chunks and voxel authority-window reentry are exempt from the hold. Stacked-sweep bench (real spec, 5 consecutive terrain applies): 640 → 355 builds executed, 79.4s → 39.6s build CPU.
  • Saturation-weighted texture tint on the primitive lane (#11989, ledger 1707). TINT_WGSL's colorize was binary: a primitive material carrying both texture and color ran luma-colorize at full strength, so a near-white color was a black-and-white conversion of the art (nickd's 8 gallery paintings, ma1neac's Sugar Rush — two creator specimens in 11 days). The law is now the FX sprite lane's mix(base * tint, colorize(base, tint), saturation(tint)): neutral/near-white tints degrade to an exact multiply (model-lane parity — near-white ≈ no-op; the accidental-grayscale class stops existing), fully saturated tints land byte-identical on the old luminance-preserving colorize (the June disco-ball/pizza panels keep their look). One shared prim_applyTextureTint serves the arrays and oversized lanes; tintStrength stays the store's binary tint-present flag; terrain WGSL tint laws untouched. VISUAL: worlds relying on ACCIDENTAL grayscale change look (they regain the texture's own colors), and textured primitives tinted with LOW-CHROMA MID-LIGHTNESS colors render 40–60% darker — the old law preserved texture luminance for every tint, the new one multiplies the tint's own darkness in at low saturation (enfeul's facility walls/floors/ceilings, oklch(0.5–0.72 0.02–0.03 240–250) → ×0.42–0.54; dig 9f25485c) — flagged for the zoo walk; version-pinned worlds unaffected.
  • Platform-lip gate gains a weight-bearing exception — the corner-landing jump lock dies (dig cf6b52b8, #12001). Landing on a convex plan-view corner, the bottom-sphere contact normal tilts only asin(d/r) from vertical (d = axis→corner distance) — well inside the slope limit, so the contact solver holds the capsule as walkable ground — but the lip gate's support probes are axis-aligned (center + ±x/±z offsets, straddle pairs requiring BOTH sides) and a corner's support lives in one diagonal quadrant: every ray missed, no opposing pair existed, and the gate demoted a stance bearing full weight. "Airborne but load-bearing" is a contradiction no motor survives: isGrounded() false forever (coyote never re-arms — jump permanently dead standing there) while fall velocity integrates to terminal (stepping off fell INSTANTLY at maxFall). The gate now verifies the OUTPUT, like the lift gate one screen up: when the motor pressed down (desiredVertical < -EPSILON) and the committed movement blocked the fall (|committed y| ≤ EPSILON), the stance bears weight and stays grounded — can bear weight ⇒ can jump. Everything the gate exists for still demotes: a walk-off lip pivots the capsule down around the edge (committed y < 0 every moving tick), and a mid-fall rim catch carries fall-quantum or snap displacement (committed y ≠ 0). Snap cannot forge the hold — it only ever ADDS downward displacement (the dig's discriminator: snapToGroundDistance=null holds the identical pose at the identical height, so the hold is the solver's, not snap's). Weight-bearing/straddle stances keep groundEntity null (no single axis-support identity; the gate's probe result is not re-probed). Pinned by corner-landing-jump-lock.integration.test.ts; snap-lip-jitter.integration.test.ts re-pinned alongside.
  • Script re-save reload suppresses onDestroy on owned spawns + one reload log line (ledger 1973, #12000). The rebind sweep's teardown (teardownAndRerunOnSpawn) now destroys previously-owned children with skipOnDestroyHooks: true — a re-save reload is a planned event, not gameplay: onSpawn-authored children re-mint under IDENTICAL ids in the same synchronous turn, so an onDestroy fired there narrated deaths that never happened (phantom casualties, ghost wrecks — the planned-events law: a planned reload must never present as an in-fiction death). Same option class as its original consumer, the cross-writer destroy preview: a destroy whose fiction does not belong to this executor. Suppression makes the reset silent, so the sweep writes ONE "log"-level line per save (logScriptSaveReloadDespawns — never "warn": planned events never present as anomalies): which scripts saved, which owners reloaded, how many spawned entities despawned, whether onSpawn re-ran, plus the teach tail — spawns made in onSpawn re-create with stable ids; spawns made outside onSpawn (events, timers, update) do not re-create until code re-spawns them. The line is the witness that gives the god-mode dangling-id warnings a cause standing next to them in the ring. Pinned by script-resave-entity-lifecycle.test.ts.

Engine v5.2.14

Released August 3, 2026

  • Safari no longer eats all your memory while a game sits waiting for its first click — the bug that could freeze a whole laptop on a quiet world is gone.
  • The erase brush now erases every painted ground material at once — one pass returns the ground to its natural look instead of only removing the material you happen to have selected (paths and roads stay).
  • Material brushes have a new Replace mode next to Paint and Erase: painting in Replace makes your material win by pushing the other painted materials out from under the stroke, instead of blending in on top. Plain painting is unchanged — it still layers additively, and brush strength still decides how strongly you cover the world's natural ground.
  • Savi gets interrupted less while you build. Routine engine notices — a texture that didn't load, a clip name that doesn't exist, a placeholder holding while art generates — now go quietly into her logs instead of pinging her mid-conversation. She still hears immediately about real breakage (broken scripts, crashes, things that stop rendering), and about any problem that suddenly happens dozens of times at once.
  • The stray scrollbar that could pop over your game's right edge (and make your game UI look broken) while art was spawning is gone.
  • Savi stops getting blamed for axes the starter template ships. Every input change in a 3D game used to print five warnings telling her to remove lookX/aimYawSin-family entries that the platform itself put in the game — noise that could bury the real error when something was actually wrong. Those entries are now recognized as the platform's own, new games no longer include them, and the warning only fires for something that can genuinely break: binding a key or stick to an axis the engine computes from the camera.
  • Placeholder images keep their shape while models generate — a tall character concept no longer renders squashed and stretched on its loading card.
  • The touch stick on phones no longer freezes and vanishes when you move your thumb slightly. In games with on-screen UI, the browser could steal a touch a few pixels into a drag (it read the drag as a page-scroll attempt), which killed the movement stick, camera drags, and hand-rolled touch pads mid-gesture. Touches on your game now belong to your game from press to release — and your scrollable panels and lists still scroll exactly as before.
  • Replace mode now does what its name says: painting in Replace pushes out the world's natural ground as well as other painted materials — a full-strength stroke leaves pure paint instead of a half-and-half blend. Erase still returns everything to natural, undo still takes the whole stroke back.
  • Ground material transitions render crisper everywhere: where two materials meet you see the dominant one with organic speckle instead of a translucent wash.
  • Terrain texture tiling now uses the same textureScale meaning as objects: meters each texture tile covers. If your world's ground suddenly looks ultra-dense, ask Savi to convert the old values (new = 2.5 / old).
  • Games with multiple areas now work in every kind of room — walk between places with friends
  • Savi can make heavy visual effects phone-friendly automatically — ask her to tune your look for mobile
  • Characters with a single animation now play it instead of freezing in a pose
  • Firefox players: sessions no longer die after tab-switching
  • Faster texture loading on big worlds
  • Multiplayer worlds with lots of places run smoothly again — the server stopped simulating places your players were already running, which was dragging the whole room's tick rate down.

technical notes

  • AudioParam writes now go through a dedupe + suspend gate at the renderer seam (Safari P0, #w-rendering 2026-08-01). Every AudioParam write — including plain .value = — inserts a WebKit timeline event (AudioParamTimeline::insertEvent, one heap allocation each); prep re-sends full state every frame (≥16 inserts/frame with zero voices, ~6 more per voice), and while the destination is autoplay-suspended nothing consumes the timeline — measured ~12 MB/s WebContent growth until the machine swaps to death. Now a write whose target equals the last value actually written is skipped (per-param last-written cache; epsilon for ramp targets), and while ctx.state !== "running" no per-frame param write lands at all — desired state parks and the statechange back to running flushes one converging refresh. Ramps still schedule normally while running (no zipper-crackle regression); bounded one-shot writes (node creation, cull fades, duck ramps, vibe edits) stay ungated. Pinned by renderer-param-timeline.test.ts (red on the old renderer, 9/9 green now).
  • The erase brush clears EVERY painted material at once (jacob's punch list, #inventors 1785628548). Erase on a material brush was a sign flip on the armed field only — erasing "dirt" left sand and grass paint standing, so returning ground to its natural look took one erase pass per palette material. An erase dab (Alt, the Mode chip, or negative-strength armBrush) now runs the paint script over every authored terrain:material:* field in the place — enumerated from the fields that actually hold paint, so orphaned fields whose material left the palette clear too. Material fields ONLY: marks, terrain:height, decor:*, and the generator's natural weights are untouched — erase returns the texel to its natural generated look. The expansion is per-dab and never persisted (brush.fields stays the armed field); commit/undo bookkeeping now iterates the stroke's touched-field ledger, so one undo restores every field the erase cleared.
  • Replace mode: paint that wins. New third Mode on material brushes (chip: Paint / Replace / Erase; session flag replace; armBrush({ replace: true })). A replace dab paints the armed field exactly like additive paint (max(current, amount), amount = falloff × strength) and additionally scales every sibling painted material field by (1 − min(1, amount)) — engine math in the session, so the paint-script contract stays one-field and overridden material scripts inherit the mode. All siblings scale by the same factor (their relative mix is preserved as they fade); at strength 1 a single core dab zeroes them. Natural generator weights still compose underneath (chunk-build's additive law is unchanged), so strength remains the lever against natural coverage: replace-at-2 visibly dominates. Default stays additive — jacob ruled additive correct; replace is the opt-in.
  • Cursor label gains "Replacing"; the Mode chip label gains the same. Non-material brushes keep the two-option chip (no sibling family to lower).
  • Pinned in brush-material-erase-replace.test.ts: erase clears all painted material fields (armed-or-not) and leaves height/decor alone, emptied fields drop from the spec, one undo restores them all; replace scales siblings proportionally (1.0/0.5 → 0.5/0.25 at strength 0.5) and compounds across dabs; Alt-erase beats an armed replace; additive-without-the-option writes no sibling chunks at all; the chip index contract; the armBrush surface.
  • Engine diagnostics adopt jacob's DM taxonomy (#inventors 1785635891): everything in logs; errors and storms ping. Every client-reported diagnostic still lands in getLogs() the moment it arrives (unchanged). Each of the 98 rail codes in engine-diagnostics.ts now carries a class: error (broken/wedged/terminal — script compile/runtime faults, renderer crashes, exhausted self-heals, physics corpses, engine parks of authored content) DMs Savi once per episode on first occurrence, with repeats counted to the log; warning (renders degraded-but-playable, placeholder shows, self-heals, teaching, environmental — texture/model load failures, missing clips, slot exhaustions, quota parks, schema skew) never DMs per instance. Either class escalates ONE storm DM when a single code accumulates ≥25 reports inside a 5-minute window ("N reports of this kind… something systemic; getLogs has every instance"). The old trailing "+N more" rollup flush — one hidden Savi model turn per active code per window — is deleted, along with its timer machinery and budget-carry branches; the perf-pointer lane (#235 hourly categories) and the room-global 5-DM/min budget are untouched. Class table pinned in engine-diagnostics.test.ts.
  • The game document can no longer paint a scrollbar (tiger, #inventors 1785641498). The engine page's html/body had no overflow guard, so any in-flow element that outgrew 100svh by a few px — in tiger's session, a transient during an art cook, frame-coincident with the kiln "Spawning art" chip — painted a full-height document scrollbar over the game's right edge on always-visible-scrollbar setups, and window.scrollTo could slide the whole canvas (measured: 8px in-flow overflow → scrollHeight 892 in an 884px viewport, scrollTop movable). Root overflow: clip now propagates to the viewport as hidden (CSS Overflow §3.3): no document scrollbar, ever, regardless of what transient DOM appears. In-game scroll surfaces are untouched — realm UI already scrolls inside its own overflow: hidden chrome-host container, and the kiln page's own scrollers (chat, settings) live in the parent document. Pinned by game-document-overflow.test.ts.
  • The input-binding validator stops billing Savi for entries the platform wrote (r-20a666a8, ravi's 08-01 playtest dig 3c3d2509). Every 3D game minted from the starters carries lookX/lookY/aimYawSin/aimYawCos/aimPitchN in inputs.axes — the starter template ships them — and the #11518 validator flagged all five as "engine-internal axis … Remove this entry" on the merged map, so EVERY patchInputs (including ones that never touched axes, including writes that later rolled back) drew 5 warning lines for entries Savi never wrote. During the 08-01 playtest the noise drowned the real schema errors in a 19-second terrain thrash. The condition was both provenance-blind and factually wrong for two of the five names.
  • lookX/lookY leave the scold entirely — they were never "engine-internal" in the claimed sense. They are binding-fed axes with an engine DEFAULT (buildDefaultBindings injects the mouse binding only when undeclared — the hasOwnProperty guard exists precisely so declarations override, and gamepad-look treats explicit rightStick → lookX/lookY bindings as the look gesture). Declarations now validate like any axis: a remap is silent, a junk source names itself, and an EMPTY lookX: {} — the one genuinely hazardous shape, since it suppresses the default and binds nothing — gets an honest teach ("replaces the engine's default mouse-look binding with nothing … remove the entry to restore the default") instead of a false one.
  • Engine-computed axes (aim*, cam*, pointer*, touchActive) warn only on the genuinely-wrong shape: a declared hardware source. input-resolution seeds each axis from the engine's setAxis write, then keyboard bindings override it and mouse/gamepad deltas add to it — so aimYawSin: { keys: "q/e" } really does fight the camera-derived value, and still warns (with the true mechanism named). A sourceless {} declaration compiles to nothing, blocks nothing, and is exactly what the platform's own templates planted — it is now silent everywhere (patchInputs rail and spec-sync console lane both ride collectInputBindingFaults).
  • Multiplicity: one line per patch, never one per axis. All sourced engine-computed axes in a map collapse into a single fault line naming them together — the 5× per-write fanout shape is structurally gone.
  • The planting stops (structural elimination). The five declarations are deleted from packages/game-starters/starter-3d.ts, cf-kernel's DEFAULT_GAME_SPEC, and kiln's DEFAULT_ADVENTURE_GAME_SPEC (behavior-identical: the engine injects the same mouse-look binding when undeclared, and the aim basis never read declarations at all). The teaching corpus stops teaching the shape: the camera skills' "Required Inputs" JSON and the @tomeapi-example camera SPEC-REQUIREMENTS lines now say these axes are engine-provided reads, declared only to remap look. The starter drift fixture is regenerated in this commit.
  • Placeholder concept images keep their aspect (tiger's squashed shinobi, dump ad7dcd12). The mcdn placeholder ladder's image rung stretched the decoded preview across the full square hologram quad (packPlaceholders packs one scalar drawn size; UV spanned the whole quad), so a 1024×1536 portrait humanoid card rendered 1.5× too wide. packPlaceholders now packs a contain-fit rect (placeholderPreviewFit — the fitted rect as a per-axis fraction of the quad, computed from the decoded texture's own dims) into the instance's two free pad floats, and the preview shader letterboxes the image to that rect — centered horizontally, bottom edge at the entity's feet, fragments in the letterbox margins discarded exactly like the BiRefNet-cut alpha. Quad size, pick volumes, crossfade, and TAA velocity behavior are unchanged; procedural/failed instances keep byte-identical instance data.
  • World touches in the authored realm survive the browser's pan-slop — the mobile touch stick stops vanishing on slight movements (incident 300a3b65, Savi-filed; enigmatic1's report, 08-01). When a game has live authored UI, the realm frame iframe is the viewport-wide hit target for every touch, and a fall-through press (target <body>/<html> — the pointer-events law) is the world's: the platform stick, look-drag, and world gestures. The frame document shipped with no touch-action posture, so the browser's touch gesture disambiguator was free to claim those presses for a pan at its ~10px slop — pointerdown → pointercancel, no pointerup — and Chrome cancels even when nothing in the chain can actually scroll (browser-gate receipt: non-scrollable host, non-scrollable frame, micro-drag still cancelled). The arbiter then correctly ends the analog claim, so the stick died ~10px into every drag — forever under its own 8.16px deadzone, which is exactly Savi's filed observable (touchActive:1 with moveX:0/moveZ:0 while the player holds the stick, "the stick widget vanishes when pressed or nudged slightly"). The shell now pins touch-action: none + overscroll-behavior: none on the frame's html/body: a world press can never start a browser pan, so its pointer stream stays the platform's from down to up. Same law, third layer — the canvas has carried it since day one (browser-host/dom.ts), kiln's game routes carry it (game.css), and the frame the UI actually lands touches on now does too.
  • Pinned by tests/browser/suites/world-touch-stick.pw.ts (real shell + real frame bundle, CDP touch): the micro-drag world press delivers down → moves → up with zero pointercancel under a scrollable ancestor chain AND when the frame's own root scroller has extent (body touches pan neither); mobile-authored-ui-actions.pw.ts's scroll-stays-scroll matrix pins that authored scrollers still really scroll (CDP flick moves #shop-list). Arbiter-level law pinned in arbiter.test.ts: an analog claim has no slop-cancel — sub-slop micro-moves plus a hold past every gesture window keep the claim alive to the lift.
  • Replace mode fully replaces — natural ground included (jacob's 50/50 crop, #inventors 1785628548). Replace previously zeroed painted siblings only; the generator's natural weights still composed underneath, so replace-at-1 over natural grass landed at 50/50 — "it's NOT winning it's merging". Natural coverage is derived (materialAt re-evaluates per texel), so there was nothing stored to write down; the suppression now persists in its own channel, terrain:natural-mask (0–1). A replace dab raises it to amount (max-composed, clamped to 1) alongside the sibling scale; chunk-build scales the generator's material result by (1 − mask) BEFORE marks and paints compose — a full-strength core is pure painted material. An erase dab writes the mask back down (subtract, like the material script's signed erase), so one erase gesture returns replaced ground to its natural look and the emptied mask drops from the spec. The mask rides the stroke's touched-field ledger: one undo restores paint, siblings, and suppression together. Marks stay outside the mask — they're authored decoration, the same boundary erase draws.
  • The mask is an ordinary field: readField/writeField see it (documented in the ObjectAPI field docs), the scatter material matcher applies it (ground the renderer shows as pure painted material reads as that material for material-conditioned beds), and the terrain inputs hash carries it name-keyed beside the painted material fields.
  • Texture transitions sharpened (the blendWidth follow-up #11801 deferred). The pbr weight resolve's crossfade window was floored at TERRAIN_HEIGHT_BLEND_DEPTH (0.18) — as wide as the ±0.18 texture-height bias that decides which layer wins each texel, so across a whole transition band the losing layer rendered THROUGH the winner: 2:1 and 50/50 paint ratios both read as a translucent ghost-merge. The window floor is now its own constant, TERRAIN_BLEND_SHARP_WIDTH = 0.06 — the height bias still carves which texels each material wins (grass blades between stones), but the loser fades out within a texel-scale band: dominant paint reads as dominant-with-speckle, partial rims dither instead of washing. Non-pbr paths (dither, tier ≥ 1 winner-take-all) are untouched. The verify-terrain-blend device gate is re-pinned to the sharpened character.
  • Pinned in brush-material-erase-replace.test.ts (mask raised proportionally, erased back down, spec-dropped when emptied, restored by one undo, untouched by additive paint) and terrain-jobs.test.ts (a full-mask texel composes as painted material alone; the mask changes the inputs hash).
  • Terrain textureScale adopts meters-per-tile (the object-material semantics). The heightmap material pack conversion (features/terrain/config/factory.ts) previously computed shader repeats-per-meter as 0.4 × textureScale — a repeat-frequency multiplier inverted from object materials' "meters each tile covers", ~1000× denser for the same small value; the knob-name collision burned a creator world authored at 0.05–0.08 (thread 1785625835). Now metersPerTile = textureScale (clamped ≥ 0.001), shader repeats/m = 1 / metersPerTile. No stored-value conversion (jacob's ruling, ts 1785626860). Defaults and tile-only materials pack byte-identical values to pre-flip; savi docs (api-reference §TerrainDef, heightmap-terrain skill) carry the flip note + conversion.
  • Instanced places work in client-hosted rooms — rail.enterPlace relay authority (#11898); server is the place host on container tier (#11899)
  • Render scripts read ctx.device ({class, coarse}, session-frozen); getClientHealth() rows carry deviceClass (#11923)
  • Per-lane draw census in render health + primitive-lane drift sentinel + refusals name entities per family (#11921)
  • Single-motion avatars play their only clip instead of T-posing; substitution named in diagnostics (#11922)
  • KTX2/Basis transcode moved off the render worker; per-mip chunked uploads (#11878)
  • Firefox: WebGPU canvas reconfigured after transferToImageBitmap — cures the Gecko session-death class (#11862)
  • Tab press-wedge cures: forced tab-up on remount, stale-press cap, no-device voice ladder (#11880)
  • The server hosts a place only when no client can (#11934, refines #11899). The permanent server host claimed the fallback seat for every loaded place even with players connected, so the container simulated every place at once (the zoo on staging: 21 physics runtimes on 2 vCPU, tick 30→13/s). Now the client election outranks the server's fallback seat — the server claims seats only in an empty room, releases them as clients arrive (epoch chain intact in both directions), and every server-side sim gate (physics dispatch, interpreter, default-place pause, place cleanup) is per-held-seat, so the server sim goes fully dormant while players are online. Cures the staging tick collapse observed in dumps 90873f77/19e6f4c0. Pinned in server-place-host.test.ts.

Engine v5.2.13

Released August 1, 2026

  • Cameras are yours now, all the way down. Savi writes your camera as a script she can read and edit — every zoom, follow, and framing choice is a line she can change — instead of flipping switches on fixed camera presets. Existing games keep playing exactly as they are; when a world upgrades to this version, Savi rewrites its camera as a script with the same feel.
  • Crouch and prone finally have a real hitbox. Savi can now resize a character's collision capsule live — physics.capsule = { height, radius } — with the feet staying planted and the visible body untouched (animations own the pose). Standing up under a low ceiling politely waits until there's room instead of launching you through the roof, and shrinking always works. No more scaling the whole player down to a miniature to fake a prone hitbox.
  • Game UI now boots on slow phones. Before, a phone that needed more than 4 seconds to fetch the UI shell was cut off at exactly 4 seconds, every retry started over from nothing, and the game's menus and HUD never appeared — no matter how many times you reloaded. Now the engine tells "slow" apart from "broken": as long as the UI shell is still arriving it keeps loading, retries wait longer instead of giving up on the same schedule, and only a truly dead connection gets a failure report (a clearer one, saying whether nothing ever arrived or it started and stalled).
  • Reconnecting no longer looks like teleporting to your game's scripts. Rejoining, reloading, or coming back from a suspended tab used to make portal effects and travel triggers fire — the world-state snap read as a huge jump. Now scripts sleep through the join and wake to the settled world, and for mid-game re-syncs Savi can ask the engine "is this a real move or a sync?" (api.isSettling()).
  • Ground-hugging objects that MOVE stopped eating your frame rate. Anything scripted to sit on the terrain used to rebuild its whole shape every beat it moved — even across perfectly flat ground — and with lots of movers that destroyed performance. Now the engine first checks whether the ground actually changed under it, and skips the rebuild when it didn't. Slopes, cliff edges, and terrain edits still re-drape instantly.
  • Objects that couldn't fit on the GPU now appear as soon as room frees up. In long busy sessions, a newly spawned object could hit a full geometry table and just never render for the rest of the session — despawning things didn't bring it back. Now the engine retries the moment space opens. And when a rendering bug does get reported, the report finally carries the renderer's side of the story.
  • Uploaded skies are finally sharp. A high-res panorama used as a skybox used to get squeezed through the same 1568-pixel pipe as every other image and come out smeared. Skyboxes now load a 4K version cooked from your original upload — horizon seams, planets, and cloud edges stay crisp.
  • Savi can now ask the engine "can a player actually WALK from here to there?" and get a straight answer with the reason — "this step is 0.37m tall; characters can step 0.3m." Before, she could only check that floors and walls exist, so an unwalkable staircase looked identical to a walkable one until somebody played it and got stuck.
  • Savi can now weld a pile of building blocks into one clean solid. Walls, walkways, and towers built from many boxes used to shimmer where pieces overlapped — now she merges them into a single seamless piece (one object, one collider), and the flicker class disappears instead of being nudged one seam at a time.
  • Big worlds can fade things by distance now, without tricks. Savi sets a visible range on any object — "draw this within 700m", "draw the cheap far version beyond 700m" — and the engine handles the swap with no flash, no gap, and no flicker when you hover right at the boundary. City-scale builds stop strobing as you fly across them.
  • 2D cameras can now be fenced at the TOP of the world, not just the sides and bottom — no more camera drifting up past the painted level into grey void during fast action. And if a camera-fence field the engine doesn't understand ever gets written, the engine says so by name instead of silently ignoring it.
  • Traveling between zones can no longer bury you in the ground or a wall. Arrivals land on the destination's real, current ground — even when the spawn point went stale because terrain was dug, sculpted, or regenerated under it — and the crossing itself can no longer carry your old zone's coordinates into the new one. When the engine does have to lift an arrival out of the ground, Savi gets a log naming exactly which stored spawn was stale.
  • Held items stop fighting the game over their size. In busy multiplayer rooms, a weapon or tool attached to a player could visibly snap between two sizes forever — the server and the player's machine each kept "correcting" the other. Now exactly one side owns that number, the snapping class is gone, and if anything ever does fight over a value again, the bug report names the exact system doing it instead of blaming your scripts.
  • Objects that sit on the ground now stay pinned to the ground when you edit them in god mode. Before, grabbing and sliding a terrain-pinned object (or just touching it with an editing gesture at the wrong moment) could quietly replace its "sit on the terrain" rule with a frozen height — and if the object happened to be mid-settle when you touched it, that wrong height stuck: things looked fine while you played, then reloaded half-buried. Now the pin itself is what saves: slide a tree and it's still a tree-on-the-ground at the new spot, lift it deliberately and it becomes a tree-floating-that-much-above-the-ground, and a touch that didn't move it changes nothing at all.
  • Terrain stops going flat when texture generation is busy. New terrain materials get generated PBR detail (height and normal maps) from their texture, and while that generation was pending — or refused, e.g. for a too-small source image — the ground rendered as flat colored tint with no texture at all, sometimes for a whole day. Now the ground renders your actual texture immediately and the PBR detail upgrades in place when generation finishes. If generation refuses your texture, Savi now gets a report naming which texture and both ways out (upscale it, or turn pbr off) instead of a misleading "fix the texture reference".

technical notes

  • Builtin camera kinds deleted — scripts are the only camera authority. The five spec camera archetypes (third-person/first-person/top-down/isometric/side-scroller), their rig implementations, the kind-swap prune vocabulary, the numeric transition interpolator, and the kind-keyed engine plumbing (pointer-lock/hide defaults, archetype state seeding, per-kind ortho zoom derivation, the renderer's first/third-person regime) are gone. One camera shape remains: kind: "custom" + behavior scripts, with the existing primitives (hideLocalPlayer, orientation authored-axes contract + getViewAngles(), pointerLock, cameraApi.raycast + collisionDist, state.orthoSize ortho zoom) carrying everything the kinds used to. The god camera and the 2D no-camera fallback rigs survive engine-internal and spec-invisible. A spec arriving at this version with a removed kind runs as custom (scripts-only semantics) and raises a structured spec-shape warning naming the migration — loud, never silent; it is unreachable except through an upgrade-path bug, since pinned worlds keep the engine that carries their kinds. The 2d-side starter now ships the canonical side-scroller camera script (dead zone / look-ahead / peek / world-bounds clamping) instead of a kind block, and the camera skills teach script recipes for every perspective.
  • Character capsule dims are authorable and runtime-writable — physics.capsule = { height?, radius? } (the crouch/prone primitive; the fixed 0.35/0.6 humanoid capsule becomes the default instead of the only option). Height is end-to-end (halfHeight = height/2 − radius, clamped at 0 — the same number traverseCheck reports as capsuleHeight); dims are entity-local and scale folds on top like every collider. Applied at the one spec seam (applyPhysicsSpec), echoed by readPhysicsSpec as realized collision truth (round-trip idempotent), and it rides the existing PhysicsBodyConfig wire codec (capsule halfHeight/radius were already on the wire), so remote followers realize the same hitbox. Non-character bodies get a one-time teaching warn (their capsule size belongs to the primitive/model + scale); the old normalizeColliderSpec warn that taught the uniform-scale workaround now names this property instead.
  • Grow admission, both backends: shrinking is always legal; a grow that would embed the capsule in STATIC geometry is HELD — old collider live, per-tick re-probe, applies the tick there is room (the held-handle "never trade a collider for a hole" idiom pointed at a new hazard). The probe is the stuck-escape's own predicate (same shrink tolerance, fixed-only + sensor-free, self excluded — one shared constant, CHARACTER_PENETRATION_TEST_SHRINK), so an admitted grow can never trip the penetration escape: no teleport-through-the-ceiling, by construction. 3D runtimes; rapier gates in getOrCreateHandle, mantle in ensureMantleEntity via overlapShape.
  • Mantle feet-anchor parity on rebuilds: a mantle body rebuild whose feetOffset changed now re-anchors center = feet + up·newFeetOffset before teardown (rapier already did this in its rebuild path) — previously a capsule shrink left mantle characters hovering at the old eye level, since the recreate seeded from the stale BodyPosition and the motor owns gravity.
  • Authored-UI shell boot: the fixed 4s load deadline became an escalating, progress-aware stall ladder (4s → 8s → 16s). The old choreography converted SLOW into NEVER on weak devices (ledger 1913, third specimen of the patience-topology class): a phone that needed 6s to land the shell document was killed at 4000ms, and the single cache-busted retry — killing the iframe aborts the in-flight fetch — restarted from byte zero under the same 4000ms, so no amount of retrying could accumulate progress; the session latched failed with "shell document never loaded within 4000ms, twice", re-armed on the next render, and died the same way forever. Now: three attempts with doubling windows (total 28s of patience for silence, still bounded for a genuinely dead shell), every retry still cache-busted (the poisoned-cached-response class keeps its medicine). Each window is a stall detector, not a duration cap — the shell HTML gained two inline one-liner progress beacons (tome-realm-progress, stage parsing at head-top / parsed at body-end; the only host-visible signal below the iframe load event, since the sandbox's opaque origin hides contentDocument/readyState), source-gated exactly like hello, and a stage ADVANCE re-arms the live window in place — same iframe, same in-flight fetch, no restart — so an observably arriving document is never killed; duplicates and spam are inert (advance-only, two stages max). The exhaustion fault stays ONE bounded ui.realm verdict and now names what the final attempt saw: "no byte of the document ever arrived" (dead route/serving class) vs "html parsed; the shell module never finished loading" (connection too slow or died mid-transfer) — the discriminator Savi can act on. Hello/ready phases unchanged.
  • Reconnect rebaselines are invisible or labeled to behavior scripts (dig 76609a1f — the "was that you moving my camera?" ghost). A join/reload/tab-reopen rebaseline surfaced as an unlabeled >8m position step, indistinguishable from portal travel — authored displacement detectors misfired on every reconnect (the specimen: an arrival-facing auto-turn snapped a player's camera toward the world origin 46s after a tab reopen). The hold: on netcode-client worlds, behavior update() and camera script hooks park until the first replicated baseline has ingested AND the reset cadence has been quiet for the settle window (1s, REPLICATION_SETTLE_WINDOW_SECONDS) — the whole boot choreography (including the observed double reset 54ms apart) happens inside the hold, so a script's FIRST observation of the world is the settled truth, even for deployed legacy scripts that will never check a flag. Boot-only latch: once released it never re-arms, so a mid-session reset storm can never freeze live gameplay. Camera rig maintenance (projection sync, first-person visibility, pointer lock) keeps running through the hold — boot frames still have a camera.
  • Felt cost, deliberate (the release's one deliberately-felt boot change): on fast kernel multiplayer joins, scripts and camera hooks start ~1s after the last boot reset — a breath before the world starts moving. Pre-baseline motion was placeholder-world motion the snap threw away; physics pause usually overlaps the window.
  • The name — api.isSettling() (ObjectAPI + CameraAPI): true while pre-baseline or within 1s of the last reset-projection ingest. Mid-session rebaselines (wake reset on tab resume — the ledger-1896 adopt-everything envelope; self-heal resets) cannot be held without freezing gameplay, so they are labeled instead. Named residue, pinned as such: unguarded legacy scripts still misfire on a mid-session takeover iff the position actually moved — the label exists for exactly that guard.
  • Relay-bootstrap worlds synthesize their baseline locally — the hold releases at that edge with no settle window. Real portal teleports after settle stay fully visible (pinned). Reset ingests funnel through applyProjectionResetAdoption, which stamps noteReplicationRebaseline on the client clock the behavior gate reads; unarmed worlds (unit tests, sim-check, server worlds) are inert.
  • Redrape pre-check — a moved usesGround anchor replays the mint's recorded ground samples before paying a generator re-run (tucker's P1, thread 1785563036; dump 9e8d30a3: tome/scripted-primitive-ground-redrape at p95 21.3ms/tick, max 294ms, 370 watched recipes in one place). scriptedGroundStateKey folds the quantized anchor transform (1/64 m feet), so an object MOVING across ground moved its change token every cadence window — and the redrape system answered every moved token with a full generator re-run (sandbox execution, mesh build, hashing, physics refresh) even when the ground under the new footprint answers byte-identically; N movers × 5Hz windows = the destruction. The key stays the cheap, never-under-triggering dirty bit; the consumer now pre-checks: the mint records its ground-call ARGUMENTS (process-local GroundSampleTrace, never on the wire) beside the recipe's existing #11170 results tape, and a moved key replays those arguments through a live sampler comparing quantized answers against the tape — all equal ⇒ a re-run would emit identical bytes (deterministic generator, identical ground answers at every branch point) ⇒ skip, costing a few terrain height lookups. Any differing answer (real terrain change, slope arriving under a mover, pose change) re-mints exactly as before.
  • Vintage honesty: recipes without a provable trace — pre-#11170 mints, over-cap groundDigest recipes, boot/restore, ownership handoff — fall back to the re-mint, whose mint re-stamps trace + tape: one paid run, then covered. An adopted row whose tape ≠ the local trace is never vouched for (element-wise identity guard). Same-system bonus: the key's O(vertices) footprint scan (per window per watched entity) is now cached per content-addressed geometry value.
  • Pins are mutation-checked (disabling the pre-check turns exactly the three skip-pins red): zero generator re-runs across 10 movement+yaw windows on flat terrain (counted by trace identity — equals-gated component writes made object identity a vacuous counter); movement onto genuinely different terrain still re-drapes; a sculpt on a recorded contact point still re-mints while a near-miss chunk bump skips; the traceless fallback pays once and self-heals.
  • Renderer census in engine-bug client dumps (dig 80a1746b; ledgers 1854/1833/1831 — third render-family investigation in a week blocked by the same instrument gap). EngineBugClientDump.render gains primitives (live primitive + bespoke record counts; count + first-12 entityIds of live entities at representation=null — the museum-blackout signature; the null classifiers waits/parkedWaits/deriveFailed/starved; per-lane arena used/ceiling rows with overflowed and gpuAllocFailed verdict bits), flags (the session's enabled render feature flags — [] is a positive all-off receipt), and drawCalls, riding the existing 1 Hz perf-sample mirror (≤1s stale by construction; zero per-frame state). Bounded (12 ids / 16 lane rows / 8 flags) and re-clamped server-side regardless of what a hostile or future client ships; old 5.2.x dumps parse to null fields, never reject.
  • Arena-refusal rollbacks became retryable — the structural half. The three silent representation=null paths (the batched-lane allocateSlot ceiling refusal + the two bespoke rollback branches) now register in store.starvedRepresentations; a bounded (≤4/prepare), epoch-gated sweep re-derives them whenever arena capacity MAY have freed (lane slot release, idle-lane disposal, device-limit grant — freed-range best-fit reuse genuinely heals). Epoch-stamping at registration is the ping-pong guard: a failed retry's own partial rollback cannot re-arm itself; stable scenes pay O(1) per prepare. An object refused at tick N renders the moment room frees, instead of staying invisible for the session.
  • Lane GPU buffer creation/grow OOM is now loud (the long-session whole-lane-blank hypothesis): error scope on recreateGpuState, once-per-lane console line + primitives-lane-gpu-alloc-failed diagnostic + the gpuAllocFailed census bit. Rollback is not representable (the buffer stays invalid until memory frees and the next grow) — but it is visible now.
  • Checked, no fix needed: a 202'd GLB already re-fetches in-session (Retry-After-scheduled re-probe + a render-on-change wake at the retry deadline, plus the slow-cook diagnostic when the loop outlives its threshold) — never-arriving assets point at the cook side, and the dump now carries that receipt.
  • Equirect skies serve from the max-res-4k variant — the 1568 canonical cap protects Savi's eyes, not the players' (r-afa42722; ravi's dig 67b78fd3 on enfeul's partner-tier report). His 8688×4344 skybox rendered from a 1568×784 KTX2 because every canonical persists at DEFAULT_MAX_DIMENSION=1568 (deliberate: Anthropic-vision-optimal + the >2000px session-wedge guard) — Savi diagnosed the cap herself in-session and was right. One pure helper (tome/skybox-texture.ts, upgradeSkyboxTextureRef) applied at the two places that must agree — atmosphere-sky-utils.ts (the DrawSkybox textureId the sky renderer leases) and spec-assets.ts (asset-manifest registration + preload hints): cdn/foo.webp → cdn/foo.max-res-4k.webp, and the KTX2 transport derives ….max-res-4k.webp.ktx2, so the GPU texture cooks from the 3840-cap variant. Non-cdn/ refs, query-bearing refs, and non-image extensions pass through unchanged. First production use of the max-res lane; staging receipts end-to-end with the specimen's actual source pixels (canonical 1568×784 unchanged; max-res-4k 3840×1920 PNG + KTX2 minted on demand).
  • api.traverseCheck — the walkability probe (r-be446670; tucker's bridge dig). Savi ran SIX collision probes on an unwalkable bridge and every one answered TRUE, because colliders existed everywhere — the bridge stepped +0.368m between slab tops against the character controller's autostep maxHeight 0.3, and walkability = f(riser, autostep) was computed nowhere she could read (~20h of blind authoring). The verb marches the segment at controller-scaled steps over the same resolved collider volumes the overlap probes use (rapier-pinned) plus the terrain samplers, reading the LIVE PhysicsCharacterControllerConfig (+ the body's resolved capsule) through the same voxel-place effective rule the stepping controller applies — the CC's own numbers, never duplicated constants. Body resolution: opts.body → caller-if-character → a player in the place → engine defaults. First blocker + WHY: blockedAt pins the failing location, blockedBy names the entity, detail carries measured-vs-limit.
  • What the controller ignores, the probe ignores: sensors never block; other characters pass through by the CC's own packed-group law; dynamic (pushable) bodies only with includeDynamicBodies: true. Pure geometry, no simulation mutation — answers identically in run_script's snapshot (where the bridge session went blind — exec-lane test included), on the multiplayer server, and on live clients.
  • Prompt budget (the #11746 convention): traverseCheck joins the Raycasting long tail as a bare name (16,097 < 16,100 cap); the full contract rides api-reference §Raycasting + TomeAPI.md; the worked example rides the pointer-raycasting skill. The pinned test suite includes the bridge specimen exactly (13 slabs, +0.368 risers → riser_exceeds_autostep, detail.riser ≈ 0.368, blockedAt at slab-0's top).
  • api.unionSolid(objectIds, options?) — the union-solid primitive (r-9b856490). Takes N axis-aligned box/cube objects (any 90°-multiple rotation; validated as a signed-permutation rotation matrix) and produces one object wearing primitive: { kind: "custom" } whose geometry is the exact boundary of the box union, computed by the 3d-rooms apex extractor (rooms/boundary.ts — one face per boundary location, owner resolved by input order, interior/coplanar-duplicate faces impossible by construction; same-paint solids pre-merge via mergeSolidsForExtraction). Per-piece paint rides the bespoke vertex lanes (linearized color multiplies instance albedo; metalness/roughness/emissive carry the negative keep-instance-scalar sentinel when unpainted). The result spawns through the normal spawn pipeline (persistence, ownership, undo, replication all inherited), inputs are destroyed after the spawn commits, and physics (when any piece had a body) is static with a bespokeMesh collider — N box colliders → 1. A shared non-default material family (water, explicit keys) survives the merge — the union spawns wearing the family key; keyed pieces merge BARE (per-piece overrides on a keyed family refuse: paint is a standard-family lane those shaders never sample). Refusals are structured ({ merged: false, reason, message, blockers[] }) and name EVERY blocking piece: not_found / not_a_box / rotated / degenerate (floor = the extractor's 10 µm lattice, so no accepted piece can quantize to zero cells) / has_children / has_parent (a union spawns as a root object and would stop following the parent) / has_behavior / unsupported_physics (dynamic/kinematic bodies, sensors, collision filters, friction/restitution — the one plain static collider cannot represent them) / unsupported_material / mixed_material_keys / mixed_places / mixed_realms, plus call-level too_few_pieces, too_big (the 16,384-vertex bespoke cap refuses instead of truncating), and id_taken (a live options.id refuses — the union never mutates or shadows an existing object). Determinism pinned by test (identical input → identical buffers).
  • Runtime-spawn lane: inline-custom geometry now collides as its mesh (ledger-906 parity). applyObjectPhysics in object-api.ts gains the interpreter's bespoke branch: an entity whose appearance is inline bespoke geometry (kind "custom", no DrawPrimitive) and whose collider resolves auto/mesh gets { kind: "bespokeMesh", geometry: <signature> } instead of falling through to the 1m default box — the "collision only at the center of the objects" class (triage 70410cc7) arriving through the spawn lane. Explicit collider shapes (box/sphere/capsule) behave as before.
  • Prompt/docs: unionSolid joins the always-on Lifecycle tail as a bare name (+12 chars, 16,020 → 16,032, cap 16,100 unchanged); the full contract (@tomeapi on the verb + result types) rides api-reference §Lifecycle; the custom-geometry skill's one-solid-not-stacked-plates law now points at the verb for already-spawned assemblies.
  • properties.visibleRange — per-object camera-distance visibility, renderer-evaluated with hysteresis (r-005e6f9a; ravi's NY-1:1 LOD dig ef4136e0). Savi structurally could not express 10-lines-of-three.js distance visibility (if (dist < 700) mesh.visible = …) without destroy/respawn entity churn or tick-lagged property writes — the observed tier swap was destroy-now-respawn-on-a-later-pass (≥4–8 blank display frames per swap, a strobing ring traveling with the camera at fly speed; three overlapping hand-rolled LOD systems in one world). Now: a DrawVisibleRange component (replicate: "aoi", forwardToRenderer: "always") carries only the authored band; the renderer registry (lume/entities/visible-range.ts) precomputes squared thresholds at write time and sweeps per frame against THAT frame's camera; verdict flips rewrite lane visible bits through the same paths a visibility-mask op uses, composing as one more AND with the camera visibility mask across every primitives-store representation (batched lanes, oversized pools, scripted, water, bespoke parts). Objects without the property pay ZERO per-frame work (registry-only iteration, size===0 short-circuits).
  • Hysteresis is engine-owned — a hovering camera can never flicker: the authored boundary is the CENTER of a ±5% band (shown hides only past max × 1.05; hidden shows only inside max × 0.95; min mirrored; between them the previous verdict sticks). Pinned: 500 camera sweeps at ±98% of the half-band produce ZERO toggles from both armed states; decisive crossings flip exactly once; a REPLACED range inherits the standing verdict (no one-frame blink); complementary bands admit at least one tier at every distance.
  • ObjectAPI getProperty/setProperty("visibleRange", …) round-trip in the spec shape; invalid writes warn and HOLD the standing range (a bad write must not silently un-range a streamed building). In-passing fixes at the same seam: refreshPrimitiveVisibility now honors OVERSIZED representations (a visible: false op on an oversized-represented primitive silently did nothing), and fresh oversized slot acquisition re-asserts current visibility (a hidden entity re-deriving into a pool slot no longer pops visible).
  • worldBounds.top completes the 2D camera fence (ledger 1870, gutterbt's grey-void report; dig 3faa4158). The set was {left?, right?, bottom?} at every layer — an authored top silently dropped (the zod schema stripped it on parse; isWorldBounds/clampToBounds never read it), so shot-follow drove the eye high and opened grey void above the painted world, unfixable by any authored rect. Now top rides the type, the schema, isWorldBounds, and clampToBounds, honored exactly like bottom, mirrored: frame top never passes top; shorter-than-viewport levels center vertically. The gutterbt repro is pinned (bounds {left:-70, right:70, bottom:-24, top:24}, camera driven to y=60 → eye pins at 19, frame top exactly 24 — no void above the paint), plus top-alone, level-entity-published top, short-level centering, and schema-parity pins.
  • The silence dies — spec-shape warning for unrecognized worldBounds fields (tolerance stays: unknown fields still no-op; once per room per field-set, a newly authored unknown field re-warns): the warn names the field, states the four understood edges, and says what an ignored field means (that edge is NOT fenced). The next authored field that doesn't exist warns by name instead of dropping traceless.
  • Named residue (row 1870's remaining shape): BOUNDS_ASPECT still assumes 16:9 (a 2.03:1 screen under-clamps horizontally ~2.75wu/side); the fence stays opt-in (derive-default-from-painted-extent is the bigger cut); unknown fields in level-entity-published state.worldBounds (runtime state, not spec) still drop silently.
  • The zone-arrival contract — transitions land on a validated destination spawn, never carried source coords (r-23615562, dig ec420521; three specimens in five days on one prod world: wall-regen embedding 07-29, "it put me in the wall" 07-31, arrive-in-the-ground 08-01). Two halves. Client: the reset client-sim guard (ledger 1848) rewrote guarded reset rows to server-stamped components only, so a traveler's OWNED transform kept source-place coordinates while PlaceMembership flipped underneath — the client rebased into the destination standing at source coords and uploaded them back as canonical (the specimen telemetry exactly: Place=gnollwarren, position=wildlands, minimap lagging). A guarded reset row carrying a PLACE CHANGE now adopts wholesale (proof of server origin — a client can never rewrite PlaceMembership on a live entity); same-place resets keep preserving owned locals (the guard's send-failure-storm purpose, pinned by test). Server: enterPlace spawn resolution checked nothing against destination ground — explicit spawnPoints, "last" memories, and authored fixed-y defaults all go stale under digs/sculpts/regens, and the KCC cannot depenetrate out of a heightfield from underneath. New tome/place-arrival.ts validates player arrivals against the destination's current terrain. Players only — authored under-terrain props stay legal.
  • Structured verdict when validation moves a player: runtime log code: "arrival-embedded-lift" naming the stale source (explicit / last-memory / authored default), from-y, resolved ground, and place — Savi sees WHY someone arrived where they did instead of investigating a mystery.
  • Named residue: cold first-residency arrivals fail open (the destination's terrain definition installs a tick after ensurePlaceResident, so there is no ground to judge at verb time — pinned by test); mesh/prop overlap at the arrival stays the character controller's depenetration job.
  • GeometryScale single-writer treaty — the server-side fit solver never writes a client-simulated entity (dig ad327a50 cuts 1+3; badgerblunts' warfare room: ~2/s geometry/scale stomps on one held weapon, 66/30s, 7,043/session, weapons visibly snapping). geometry-fit-solve ran mode:"both" with zero simulator guard: a server-side solve on a client-simulated entity was a LOCAL-origin write → forwarded, fencing the owner's uploads and replacing the owner's local values — with realm-skewed bounds inputs the two solvers disagree indefinitely. The engine fighting itself; no script to stop. Now it adopts terrain-reanchor's exact treaty: server half skips client-simulated entities WITHOUT consuming the dirty mark (write duty returning re-solves immediately); observer half consumes the mark as bookkeeping (the simulator's replicated rows carry the answer); fresh-boot/headless/exec/singleplayer worlds solve exactly as before; unowned place content under client-auth pauses until a host claims it (the same pause terrain-reanchor and behaviors obey). The fight class is structurally unrepresentable: the simulator is the only realm that writes.
  • Sustained-fight verdict that names the writer. Same-(entity, component) fence drops crossing 50 in 60s escalate the per-client teach to ONE structured verdict per pair per window (data.verdict = "sustained-write-fight"), and a new local-write observer seam (setLocalWriteObserver, fires only on LOCAL-origin marks) + the executor's ambient withSystemContext bracket let the verdict name the actual writer: server-side writer "tome/geometry-fit-solve" is fighting the owner of <entity> over "geometry/scale". Hook/cron/run_script writes (outside any system bracket) stay unattributed and keep the script-lane diagnosis — correct for that family. The old blanket "a server-side script keeps writing it" was wrong for engine-writer fights and unactionable.
  • Dump surfacing (the fence lane is DD-invisible by construction): EngineBugRealmContext.uploads now carries uploadDrops per gate + serverWrites (including sustainedFightVerdicts) in every engine-bug/incident dump. Enforcement is byte-identical — the verdict is a receipt, never a behavior change (pinned).
  • God-mode gesture commits preserve terrain anchors — the spec's y: { terrain: offset } form is the truth; the live resolved y is only its projection (cal's Final Abyss dig, dump 4456c18d). commitGestureEdit diffed and persisted feetPosition through getProperty — resolved world coords — so ANY gesture touching a terrain-anchored root baked its current live height into the spec as a concrete number and deleted the anchor. When the live projection was wrong (not-yet-streamed terrain resolution, a script re-seat mid-flight, cross-realm height divergence), the wrongness became durable: cal's boundary trees sank 6–10m at the touch and reloads kept them buried, while his ground-warden script re-lifted them live every 2s — "looks fine until it suddenly doesn't", and "not persisting" on every boot. Now readEnvelopeValue reads feetPosition in the frame the spec stores it (the live TomeTerrainAnchor component, root-only — armed by every terrain-form write, removed by every concrete write, so it always mirrors the last form a writer chose): before/after compare form-to-form, a gesture that never rewrote the pin contributes no feetPosition diff at all (zero-move touches and menu picks bake NOTHING, even while the live y wobbles underneath), a slide persists the pin at the new x/z, and undo restores the authored form. Bonus determinism: the commit diff no longer depends on per-realm terrain samples, so client and server record identical undo entries by construction.
  • Horizontal slides carry the authored offset verbatim. The translate pad and the spline move pad derived their terrain offset from liveY − terrainHeight at press — a wrong live y turned a purely horizontal slide into a durable vertical edit. Both now read the offset (and the f64 x/z base) from the object's authored spec form when it is terrain-anchored: { terrain: 0 } slides as { terrain: 0 } exactly, with no terrain-sample round trip to smudge it, and the y-less { x, z } shorthand keeps its shape. Unanchored (concrete-y) roots keep the live derivation — the pad converts them to terrain-relative at the float the creator sees, which is the pad's long-documented intent ("persists a terrain-RELATIVE y … rides later terrain edits instead of being buried") finally reaching the spec instead of being re-baked concrete by the commit. Deliberate vertical lifts keep their as-found rule: they re-author the pin's offset ({ terrain: newOffset }), still terrain-form. Snap TOP-STACK mates still re-derive (a deliberate vertical correction). Pinned red-first in terrain-anchor-commit.test.ts, including the exact cal shape: spec { terrain: 0 } + live y corrupted meters downward + horizontal touch → the pin survives and the wrong y never bakes.
  • Coverage boundary against the LIVE-PARKED-ENTITY class (dig 6f5f9bb5 — cal's XZ teleports to glade center: live entities sitting at the park transform (origin x/z, y=3.8, identity scale) because spec position was never applied while scale was; receipts: ha-tarn-mourn-5 durably baked to (0, 3.8, 0) at v16197, plus four kernel identity-scale resets v16193/v16196/v16201/v16203). Pinned by test, honestly: (1) a zero-move touch on a parked object now bakes NOTHING — the grab-verbatim park write (v16197's shape) is dead at commit; the one residue is an undo entry whose before is the resolved park (no anchor was ever armed, so the press-time envelope falls back to the live read) — undo after such a touch re-bakes the park. (2) A real drag on a parked TERRAIN-ANCHORED object lands at authored+delta — the pads base x/z on the authored spec row, never the live park — so the teleport-toward-origin is cured for the terrain-form specs cal's trees actually carry. (3) NOT cured, the follow-up's cases: a real drag on a parked CONCRETE-y object still persists park+delta (no authored terrain form to read); deliberate-vertical grips on a parked object still derive from the parked live; and script-lane editors (getProperty/setProperty in game scripts, e.g. the session's editor.js move arrows) never pass through this commit at all. The root — the silent partial transform-apply that parks the entity — is the named follow-up; fixing it re-arms the anchor and closes every residual above at once.
  • Zero-effective-delta touches of concrete-writing handles bake nothing (found in adversarial review — the form-based diff would otherwise have OPENED a bake path master didn't have). Several drag handlers write concrete feetPosition from the press-time live snapshot on every drag tick, including a tick with zero effective delta: the cylinder cap extrudes (hover + declared grip), the plane edge extrude, and the 2d-top/2d-side pad branches. On an anchored root, that idle write strips the live TomeTerrainAnchor (concrete write = de-pin), so the commit diffed form-before vs resolved-after and baked the live — possibly corrupted — height on a plain press+release; master's resolved-vs-resolved compare baked nothing on that same touch. All five sites now skip the feet write at zero effective delta (no movement, no write — which also stops an idle touch from silently de-arming the live anchor, a live-only master bug). Pinned red-first: cylinder bottom-cap hover extrude, zero-move touch, corrupted live y → spec byte-identical, no undo entry. Real (nonzero) resize drags keep their as-found rule: cap/edge extrudes persist concrete feet — the "never de-pins" rule is the MOVE grips' (pads, lifts); resize extrudes that genuinely move feet still convert, as they always did, and undo now restores the authored form.
  • THE COMMIT SEAM — one boundary rule closes the whole zero-move-bake class, for every handler present and future. The per-writer guards above fix the sites they name, but the class was wider (review round 3): cube extrudes write concrete feet unconditionally — and for axis=y sign=1 the shift is hardcoded 0, so even a REAL grow-taller drag wrote value-identical concrete feet and de-pinned the root; the vertical grips (terrain-offset, verticalMoveGrip) write { terrain: liveDerived + total } at total=0, baking a corrupted live y as a wrong offset; writeAnchoredScale does the same on the fallback/model handle set — the handles cal's actual trees carry. Instead of a third round of per-site guards, the commit now owns the rule: captureUndoEnvelope records the LIVE feet as the gesture found them (GESTURE_LIVE_FEET_KEY), and commitGestureEdit drops feetPosition from the diff when the release-time live feet are byte-identical — "a gesture that left the live feet where it found them persists no feet change". That is master's own zero-move semantics (its resolved-vs-resolved compare could never persist feet without live movement) restored structurally, composed with the form-based diff for genuine moves — so the seam can never bake where master didn't, and it also stops the zero-move pad touch of a concrete-y root from phantom-converting the spec to terrain form. Pinned red-first per family: cube face touch, cube top-grip REAL grow (scale persists, pin survives), vertical-grip touch on a parked object, model size-star touch, concrete-y pad touch. The per-writer zero-delta guards stay as live-write hygiene — an idle touch must not de-arm the live TomeTerrainAnchor even transiently; the seam protects the spec, not the live component, and the unguarded families still de-arm/re-derive the live anchor on an idle touch exactly as master did.
  • Out of scope, named for the follow-up: the gesture-seam reconciliation (client-predicted vs server-applied divergence inside a drag — what produced cal's 6–10m mid-gesture drops). This change removes that divergence's path into the SPEC; reconciling the live projection across realms during a gesture is the #11775 zone-arrival doctrine transposed to god-mode object writes, and it is not built here.
  • Terrain raw-albedo fallback — a pending or refused Patina cook no longer degrades new terrain to flat tint (dig 554e8cd9, jacob's "blurry with lines" 08-01). Since pbr-by-default (#10552, prod ~07-26) every new textured terrain material's textureId is the Magic-CDN Patina albedo+height variant (<albedo>.pbr.json.png.ktx2?transform=albedo-height), and until that derived source lands the atlas rendered the layer as its tint color over a 1×1 placeholder — so a fresh mint (cook minutes), a starved cook queue (the 07-31 storm), or the 24h Patina 4xx reject park (c9c080f68f) all read as textureless watercolor ground. Now buildMaterialPack threads the raw albedo the variant was cooked from as fallbackTextureId, and the terrain layer atlas requests it whenever the variant isn't already resident: the raw texture joins the array and renders (the pre-07-23 look — alpha-as-height off while it serves, so height-aware blending never reads opacity as height), and the cooked variant supersedes it the moment it lands (join priority: a fallback never displaces a resident or batched primary; the warm path — variant already resident — never fetches the raw albedo at all).
  • Reject-seam honesty: terrain-atlas-tint-fallback now fires only when NO source can serve the slot (variant rejected AND raw albedo absent/rejected). A variant that can't pack while the raw texture serves fires the new milder terrain-atlas-raw-albedo-fallback teaching ("ground stays textured, PBR surface detail is what's missing"), and a variant that rejects while its raw albedo was warm-path-deferred kicks the fallback load right then.
  • Cook-refused verdict (texture-pbr-variant-refused): a Patina-derived map URL that dead-parks or tombstones at the texture service (the kiln 24h reject verdict serves a descriptive 400) no longer teaches the generic "fix the texture reference in the spec" — the engine minted that URL itself, so the teaching misdirected Savi at a ref she never wrote. The new verdict names the SOURCE texture, says the raw texture keeps rendering, and puts the fix where it lives: upscale/regenerate the source, or pbr: false. Teaching leads, server detail trails (the diagnostic rail truncates from the end).
  • Named residue (follow-up, dig shape 3): blendWidth = max(fwidth, 0.18) on the pbr shader variant is untouched this PR — height-aware blend width without landed heights is a separate softness contributor and deserves its own change.

Engine v5.2.12

Released July 31, 2026

  • 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.

Engine v5.2.11

Released July 30, 2026

  • Savi can now measure your world instead of eyeballing it: asking for an object's bounds answers the real loaded size of models (stacking, fitting, and aligning become arithmetic), and while a model is still downloading she gets an honestly-flagged placeholder or a clear "still loading" note instead of a silent nothing — so things land ON tables, not 0.18m into them.
  • Savi can now see your colliders. Ask her to check a collision problem and she can capture the world with the actual physics shapes drawn over it — including invisible walls and floors — instead of doing blind geometry math. The same look tells her whether the live world still matches what was built (a recycled room once silently un-solidified 143 islands; that class is now a one-glance read instead of a 20-minute hand-rolled sweep), and she can frame a shot on an invisible collider directly. If you use the debug inspector (?debug), its Physics tab now shows the same live-vs-spec verdict. Nothing changes in your game: the wireframes never appear on players' screens unless you toggled them on yourself.
  • If the room restarts while you're in god mode, you come back cleanly in play mode — no more being stuck half-in/half-out where you move way too fast, look choppy, or the God Mode button does the opposite of what it says.
  • Savi can no longer accidentally break your controls by deleting god-mode's internal camera entity while cleaning up — the engine refuses and tells her why.
  • Your game's own buttons keep working while you're in god mode. Before, clicking a menu or HUD button you built did nothing until you left god mode — now the click reaches your game's code either way. God-mode flying and editing controls are untouched.
  • A script that probes for an API member that doesn't exist (world.screen && world.screen.width) no longer crashes — the check just comes back false, and a one-time note in the logs names the near-misses. Calling a method that doesn't exist still fails loudly with the full list of what does.
  • Savi can now ask the running game "what object is that?" — by a spot on the screen, a point in the world, or a half-remembered name — and get the exact object ids with their size and place, instead of guessing from her code map. Fewer wrong-object edits when you point at a thing and say "fix that".
  • Characters can now hold a gaze: ik: { head: { target: { yaw: 180 } } } turns a head to face straight behind the body (any angle, any chain — degrees relative to where the body faces) with no bone math and no world point to keep updated. It sticks through world saves, follows the body when it turns, and a pose set before a model finishes loading simply takes effect the moment it lands — no more re-kicking scripts after restarts to straighten tilted heads.
  • Friends joining a multiplayer game no longer break scripted objects for a moment (or for good): a car built by an onSpawn script could arrive "in pieces" — duplicated parts, physics jitter — because the joining player's machine re-ran the build script it had no business running. Now only the machine actually simulating the object runs its scripts, and joiners just see the real, already-built state.
  • Games left running on old engine versions could get "stuck": the room played fine for whoever was already inside, but every new join spun at "Starting game…" forever, and reloading just landed on the same stuck server. The platform now notices a room that keeps refusing to let anyone in, restarts it on the spot, and tells the waiting player honestly that it's retrying — so the next attempt actually gets in.
  • Camera scripts that read a held key (a lean axis, a zoom axis, any inputs.axes entry bound to keys) no longer jitter on high-refresh displays. Holding the key now reads steady at full value every frame; releasing it still reads 0 the moment you let go. Mouse-look feel is untouched.
  • Editing a generator script now updates every object built from it — including objects your scripts spawned at runtime and objects in rooms that restart later. Before, some of those kept geometry from the old version of the script forever, and players who joined after the edit saw nothing at all where they stood.
  • Visual effects left behind by players who disconnected no longer pile up invisibly and slow your game down. One-shot effects (impacts, muzzle flashes, explosions) minted in the last moments before someone leaves used to be stuck forever — eating effect slots and CPU every frame for everyone still playing. The engine now cleans them up automatically, on the same schedule they would have cleaned themselves up. If you wrote a janitor script to sweep these, you can delete it.
  • Sounds that fire on every click or hit — a creature's cry, a UI confirm — used to pile on top of themselves, getting louder and louder until they broke up. You can now say mode: "restart" on playSound and rapid retriggers replace the playing copy instead: one voice, steady loudness, no click. Sounds you don't mark keep layering exactly like before (gunshots still stack — that's their texture).
  • playSound now always hands back a sound id, and stopSound(id) actually stops one-shot sounds too — cutting a long sound short finally works.
  • Occasionally a save would report success but a busy game room kept serving the previous version until the next save happened to land — players saw old content, telemetry reported the old version, and nothing anywhere looked wrong. Rooms now detect that their copy of the game is provably behind a save they were told about and re-fetch until they catch up.
  • Fixed another way a player in a relay room could silently miss your latest save: if the one "new version!" message got lost on the wire, that player kept playing the old world until your NEXT save — with a lone player in the room, nothing would ever correct it. The room now re-states its current version in the background heartbeat (every 2 seconds), so a player who missed the memo catches up within a couple of seconds, automatically.
  • Live edits now always reach everyone in a relay room, even through a brief network or storage hiccup. Before, if a player's client missed the ~3-second window to download your latest save, that player silently stayed on the old version of the game until your NEXT save — you'd see "saved", they'd see the old world, and nothing looked wrong on either side. Now the client keeps trying (backing off politely) until the save actually arrives.
  • Roads are one mark now: give a road a spline of [x, y, z] points and the terrain flattens to your curve, the surface paints along it, and grass and trees keep off it — one source of truth instead of hand-syncing a flatten strip, a paint stripe, and clear-scatter patches that drift apart. Corners round like a real road instead of kinking at every point.
  • When a player's game tab is running an older build than the server (usually right after we ship an update), their view could silently miss or mangle objects with no error anywhere. Now both sides say exactly what happened — and Savi can see it too — with the fix spelled out: that player reloads the page.
  • Fixed the last known way a solo player could silently miss your latest save: in singleplayer, if the one "here's the new version" message from the server got lost, the player kept the old world until your NEXT edit. The room's background heartbeat now tells the game client which saved version (and content) it should have, and a client that provably missed one quietly re-fetches it within seconds — while normal autosaves of the player's own progress stay exactly as quiet as before.
  • Repeating textures on world sprites now behave like you'd expect: set uvRect: { x: 0, y: 0, width: 4, height: 1 } and the art tiles 4× across the sprite — it stays lit under your lights, batches like any other sprite, and clicks/raycasts hit it. Previously asking for a repeat silently turned the sprite into an unlit background plate that followed the camera.
  • Scrolling parallax backgrounds (the camera-attached strip recipe) are unchanged.
  • Multi-part actors (bodies built from parented objects) no longer stay dismembered. If a part ever comes unstuck from its body — the bug where heads and limbs froze in the air while the body walked away — the engine now re-welds it from your game's definition within a second, and reloading or rejoining a room always restores bodies intact.
  • Scrolling a list inside a creator panel or any interactive game UI no longer also zooms the world camera behind it. The panel scrolls; the world holds still. Scrolling over the open world (or plain HUD text) zooms exactly as before.
  • Games whose scripts read lots of objects per tick run noticeably smoother — reading an object's position no longer pays hidden costs for forty properties the script never asked about. On busy worlds this was the difference between a stable tick and visible stutter.
  • If a playSound call carries an option the engine doesn't know (like an invented soundId:), Savi now gets told once — with the real option list — instead of the call silently ignoring it.
  • If stopSound is handed an id that was never a real sound handle, it says so once (stopping a sound that already finished stays quiet — that's normal, not an error).

technical notes

  • The 5.2.11-cut fps latch is dead before release (#11640). Two freight commits interacted into a silent full-spec fetch live-lock on the sim worker (fetch loop re-entered hot when a plain adopt gated on the per-realm revision counter while the covered-check spoke dbVersion) — 112fps pinned to ~22 after any pointer skew, forever. Non-covering resolves now take the level-triggered miss ladder and kiln saves ahead on dbVersion adopt past snapshot-inherited revision counters; a progress gate makes the hot re-enter unrepresentable.
  • God-mode flight reaches the server (#11630). The god entity's ownership walk attributed it to the creator, so server input dispatch skipped it — the flight existed only in local prediction and exit honestly snapped back. One law at four seams: god-mode entities are server-simulated; the owner's rig sim is presentation-only.
  • Focused text fields stop eating game keys; solo write-fence tells the truth (#11624). Readonly/disabled fields release the keyboard ([data-input-capture='ignore'] outranks editability); the solo-session drop was correct two-writer enforcement wearing a lying receipt — the receipt now teaches, and sustained fencing escalates to a warn.
  • Alt-tab no longer wipes script state (#11622). The projection sweep destroyed verdict inputs mid-iteration (make-before-break re-baseline, one verdict per entity); the suspension-takeover wake handback is wired and pinned.
  • God-mode exit position pins through production netcode (#11626). E2e pins drive the full egress/wire/ingest path in five hide/show permutations.
  • getWorldBoundsBox becomes honest about pending model loads (demand row r-0223b62a, engine-demand wave p-35fbe727 item 2). The bounds read has always measured real resolved geometry (BoundsRegistry: GLB header POSITION min/max, or replicated assets.metadata from a prior parse) — but a model whose bounds hadn't landed answered a bare null, indistinguishable from "nothing drawable", and Savi assembled blind (the 0.18m-eyeballed lantern, a-1389). Now: while measured bounds are unknown, a FULLY declared properties.layout (per axis maxExtents else minExtents; all three axes or nothing — a fabricated axis would be a lie) answers as the placeholder box flagged geometryPending: true, in post-fit meters × authored scale with GeometryScale deliberately skipped (declared extents are already the fit target; a held-stale factor would double-fit). Partial or absent layouts still answer null, now with a loud-once (entity, script) runtime-log teach naming the loading model and both cures. Subtree unions (getWorldBoundsBoxWithChildren, god-mode grips) propagate the flag when any contributor is a placeholder. The read stays pure: no waiters parked, no spec mutation, no components touched (pinned in world-bounds-api.test.ts). Docs regenerate from the source JSDoc; getWorldBoundsBox joins the always-on Properties scent (the invisible-verb law — coverage-test ledger entry 2026-07-30) and a BOUNDS_DRIVEN_ASSEMBLY example rides api-reference.
  • Collider overlay in Savi's eye (gap 2): view_live_scene({ colliders: true, camera|frame }) composites the F3 collider view's exact wireframes into the capture — same sim-side builder (physics/systems/collider-view.ts), same lume overlay-lines geometry, same color code (static green, character cyan, dynamic/kinematic orange, sensors yellow-dashed). A counted debug-state hold (armSceneViewColliderHold) arms the builder for the capture's duration without opening the inspector; hold-only views carry captureOnly and are excluded from the player's live frames — no wireframe flash while Savi looks. The capture appends collider draws after scene draws (always-on-top) and reports colliderBodies, so a still-arming overlay captions an honest zero instead of "no colliders exist". Composes with burst. The plain viewport form refuses the flag loudly (the player's literal screen never wears wireframes).
  • Live-vs-spec drift read (gap 3): new tome/live-spec-drift.ts owns the comparison Savi hand-rolled as a 314-object sweep while a recycled room kept wiping physics off the same 143 objects (dig 2bdedc41). Bounded to where it is structurally true: objects whose spec DECLARES physics; body-type equivalence through the spec's own words (character/vehicle compare presence only); resting feetPosition only for live-static bodies with plain-number authored positions (1mm epsilon); spec objects with no live entity count missing, never guessed at. Exposed on the debug surface family: the colliders: true capture result carries the report (over the physics.liveSpecDrift debug request — the include-only/never-arm rail), and the physics inspector panel appends the one-line verdict (2s-cached compute; also reachable via __spawnCollectDebugConsole panel pulls with the inspector closed). Zero cost while no debug surface asks.
  • Frame-index collider lane + runtime-spawn coverage (gap 4): frame-mode object resolution (scene-view-bounds.ts) gains a collider-outline lane — own visual → parent-linked subtree → collider outline (armed overlays only) → honest miss — so an object that renders NOTHING (visible: false collider treads, invisible walls) frames by its collider shape when colliders: true is on. Runtime-spawned bodies ride every lane identically to authored ones by construction (the builder reads PhysicsBodyConfig off the live world, never the spec) — pinned by tests, alongside a pin that the original row shape (frame-index NOT-FOUND for rendered runtime spawns) stays dead (#10828 killed it).
  • God-mode half-session no longer survives a reconnect (ledger 1886, dump 2f28abea). A room restart mid-god-session left the client holding replicated session state while the fresh room joined the player in play mode — the join reconcile reaps god wreckage server-side only (tome/lifecycle.ts), and reapGodModeWreckage had no client-realm caller. Now noteProjectionReset (the client's reset-boundary reconcile, beside the speculative-control/sim-lease/travel-cover flushes) reaps any god session whose god entity the reset snapshot did not re-show, through the same one teardown every other exit path uses (tome/god-mode/session.ts). A live session the reset re-shows (suspend-resume wake, mid-session recovery resets) is untouched; relay-wire worlds never reap (the local world IS the god-mode authority there).
  • The raw-flip toggle is dead on the wire (the structural leg). dispatchGodModeToggle shipped god-mode.toggle with no {desired}, so a desynced exit INVERTED into an enter — enfeul's specimen: the first-entry DM re-fired in the fresh room and the server parked his player at the god pose (a 300 m owner split, god flight speeds in normal play). The sender knows which way it means the flip; now every shipped toggle stamps desired (the negation of the client's own replicated view) and the server's existing set semantics absorb a desync as a no-op. Old clients' bare frames still flip (decode-compat) and the dispatch log line carries legacyRawFlip: true so the raw-flip population is watchable. Local-authority dispatch (singleplayer, relay wire) keeps the bare flip — sender and authority are the same world.
  • api.destroy('god/player/…') refuses with the teaching (the pre-#386 wreckage class). Savi's live repair destroyed the engine god entity from game code, out from under the session — dangling control-mapping halves, the exact class the one teardown exists to prevent. ObjectAPI destroy() now refuses the engine god namespace and any entity a live session claims, before any rail forward or local despawn; the error text teaches that sessions end through the engine's own exits and leftovers reap automatically. Non-god destroys, zero-match honesty (ledger 1247), and the server-side reconcile are all untouched (pinned).
  • God mode no longer eats the game's own UI buttons (ravi's badgerblunts read cec6b199 §F′, request r-f670d207). The control-target resolution in tome/input-applier.ts routes the whole input frame to the god rig while god mode holds TomeControlTarget, so a creator's authored sendAction('join') button was silently dead exactly when they were most likely to be testing. The swallow was incidental, not designed — nothing suspended authored actions on purpose. Now sendAction rides the wire object-shaped whether or not it carries a payload (input-resolution.ts; hardware binding pulses stay plain strings, so the shape IS the origin bit), and the applier fans the sendAction plane — object-shaped, non-god:* actions only — to the god-moded creator's parked body with empty axes. Hardware stays the god rig's (Space flies the camera, the parked avatar never jumps), god:* chrome stays the god rig's, vehicle/turret control transfers keep the documented "handle it there" routing, and the primary god dispatch is byte-identical to before.
  • Unknown-API teaching: reads never fault, only invocations do (badgerblunts §6, request r-eb34af9f). The teacher's throw-on-property-read semantics detonated the universal JS existence probe — world && world.screen && world.screen.width threw every render frame from inside the && guard, and a taught try/catch swallowed it into a 46-minute invisible-UI incident. Now a read THROUGH a teaching thrower (api.screen.width) returns undefined and delivers the full teaching message once per name via console.warn (getLogs / ui.log), and a namespace miss (music.fadeOut) answers with a callable thrower instead of throwing at read time. Phantom CALLS are unchanged and stay the loud teaching channel: api.updateMaterials(...), music.pause() throw the same teaching TypeError text as before (ledger #699 / ledger-805 messages preserved verbatim). Named residual: a bare if (api.foo) probe still reads truthy (the documented tombstone tradeoff), and world still has no real viewport surface — the world.screen reach itself is an API gap left open deliberately (new surface gates on vocabulary review).
  • The identify verb — savi's "what object is this?" query (r-8c412240). Third eye of the inspection family: identify_object maps a screen position (0..1 image fractions through the live camera), a world point (nearest-by-bounds, 0 = inside), a world ray (first visual hit per entity, terrain included), or a name (case-insensitive id match: exact > prefix > substring) onto object ids with kind + world bounds + position + parent link. Resolution is renderer-side over the live lume stores (renderer/scene-identify.ts, riding lumeSceneRaycast and the scene-view bounds vocabulary), so matches are RENDER truth — colliderless spec dressing matches (the spawn-pusher verdict's six-wrong-objects case), and by name a live-but-unrendered record answers honestly with bounds: null. Every lane's ordering is deterministic and pinned (__tests__/scene-identify.test.ts); a miss is an honest empty, never an error. Wire: one new identify renderer-worker message + the identify_object client RPC (spawn.ts dispatcher, _entry/client/scene-identify.ts validation); chat-side the tool enriches matches with authored-spec facts (place, one-word visual summary, tags, behavior, parent — authored: null = runtime-spawned) and translates the pre-verb engine's "Unknown client RPC method" into the engine-version fact. No spec words, no ObjectAPI surface — a read-only query lane.
  • Head-pose/gaze primitive: direction-form IK targets (r-29637275). { yaw, pitch? } in degrees resolves at the sim plane from replicated entity state alone (feet + rotation → a far aim point at 1000 m along the body-relative direction) — deterministic across realms, follows the body as it turns, needs no bone reads at authoring time, so nothing exists to misread before the model loads (the class savi's savi-gaze-steer.js/savi-behavior-kick.js kick-scripts hand-rolled). Direction-authored channels carry wide look clamps through DrawIK (maxYaw 179°, maxPitch 89° — a stated pose is honored; the exact antipode is excluded because a 180° swing has no unique axis), consumed only by the head solve; point/object/script channels carry no clamp fields and stay byte-identical (pinned). updateIK/properties.ik/getIK all speak the form; getIK echoes the authored direction: { yaw, pitch } alongside the resolved point.
  • Pose/IK application is model-load-order independent (r-29637275's second demand). draw/ik and draw/mixer are change-driven renderer forwards; a value arriving while the entity had no model visual was dropped by the store and never re-sent, so a pose set before the model property landed was permanently lost when the visual was created later. The models store now holds the latest ik/mixer per entity (the shadowIntents pattern) and seeds visuals at creation; a model remove + re-add re-dresses from the hold (the components still live entity-side), and DESPAWN disposes it. IK/mixer arriving while the asset is still loading was already held on the visual and is unchanged.
  • Joining clients no longer replay hosted onSpawn (ledger 1867, tucker's sev — the car-in-pieces join): kernel-tier clients are spec-built + delta-corrected, and spawnObject ran the composed onSpawn with zero authority consultation on whatever client materialized a spec object locally first. At join, the global-AOI spec can land before an entity's AOI-scoped replicated CREATE row, so the joining OBSERVER built race-car from def bytes and replayed the host's onSpawn — deterministic-id duplicate children, local physics/pose stomps, a locally-diverged pose the host's fan-out then fights forever. The replay fired even with the host table present and naming another client: the spec-build lane never consulted it.
  • The fix is one predicate at two lines, not a new mechanism: spawnObject and teardownAndRerunOnSpawn (the script-edit rebind sweep, which re-ran onSpawn ungated on every client per revision — same class) now skip execution when isClientAuthObserverEntity — the existing classification behavior-update/interaction-dispatch already gate on, fail-closed pre-table (absent host table → not simulated here → skip). The entity still materializes everywhere (spec-built warm start; late-join visuals keep working) and the simulating side's fan-out carries the authoritative state. Observers skip the rebind teardown too: their owned-children rows are replicated entities whose destruction (and onDestroy hooks) belongs to the simulator; compile-registry updates stay unconditional, so an observer's compiled spec stays fresh for a later host adoption.
  • Genesis execution is unchanged on every authority: server worlds keep running onSpawn at spec apply — including RELAY-TIER server worlds, whose applySpec deliberately compiles and runs object behaviors as the world-structure generator class (sim-tier.ts option-(b) carve-out), so no deferred re-delivery machinery is needed; singleplayer clients keep running everything (the local client owns everything); the named place-host client keeps its runs.
  • Pins (join-hosted-onspawn-replay.test.ts — the 5 core pins dug and red-tested by the pin-1867 dispatch, report cfd3e5a1, adopted with the fix): observer + absent table and observer + other-client table execute nothing (formerly red), host client / server world / singleplayer keep executing (controls); plus relay-tier server genesis, and the rebind-sweep sibling lane (observer skips the revision re-run; host client and server keep it).
  • Join-liveness watchdog (ledger 1888, the warm-room join-starvation class): a room on the old 4.2.0 pin admitted only bootstrap-time sockets and silently starved 13/13 later joins (~25–35s each, zero egress) across two multi-minute windows — every reload landed on the same wedged-warm worker, and the idle reap was the accidental cure. The shell now treats starvation as evidence: JOIN_STARVATION_RECYCLE_THRESHOLD (3) CONSECUTIVE starved joins (each one a full 45s join-honesty deadline of proven zero egress) while the room holds ZERO admitted players recycles the isolate NOW through recoverFromWedge — the same teardown the tick deadline uses, escalation ladder to the terminal park included. Conservative by construction: one admission anywhere resets the streak (SocketGateway.onFirstFed), a room with live admitted players never recycles on a newcomer's starvation (hasFedConnection gate — that starvation stays log-only, stamped roomHoldsAdmittedPlayers/starvationStreak on the room-do.join_starved row), a slow-but-alive boot feeds its first socket inside the deadline and never strikes, and every recycle resets the streak so a wedged replacement must re-earn all N strikes.
  • room.join_starved finally sent (reserved since ledger 1460, named by 1695(b)): fireJoinStarvation sends the verdict frame immediately before the retryable close, so the client can say "room stuck, retrying" instead of an eternal "Starting game…". The reservation's hazard — a pre-close frame on a RETRYABLE close resets the client's first-message authenticated latch and reconnect budget every cycle, so a permanently starved room loops forever — is discharged by byte identity, the LIVENESS_BEAT_FRAME pattern: the message is now field-less so the whole frame is one constant (JOIN_STARVED_FRAME) the client latch exempts before parsing. Past the latch it parses as a normal control message; the runtime worker posts the honest "The game server is stuck letting players in — retrying…" loading state (a parsed server verdict outranks the boot-flap silence gate), cleared by the boot's real Ready. Client and server ship in the same engine bundle, so the sender and the exemption always travel together.
  • Pins (room-do-join-starvation.test.ts, room-runtime-resilience.test.ts, connection-presentation.test.ts): the 1888 shape recycles at N and the fresh isolate admits again; live-resident rooms never recycle; an admission between starvations breaks the streak; healthy churn accumulates nothing; the verdict frame reaches the same bounded reconnect terminal as the no-frame arms (budgets never re-armed) while still parsing into ingress; the starved verdict posts pre-Ready and clears at Ready.
  • Held level axes no longer fabricate a release on frame-less render passes (ledger 1879). runFrame's steps==0 branch runs renderPrep alone at display rate between fixed 60Hz sim ticks; camera-behavior calls onInput unconditionally with framesSince() empty, mergeCameraAxes([]) produced {}, and the input Proxy minted 0 for every spec-defined-but-absent axis. Any custom camera reading a held key-bound axis on a display outrunning the tick rate saw the axis oscillate commanded/0 at the render/sim beat — tiger's lean camera equilibrated at ~half depth with a 60Hz roll shiver, and his script-side guard was defeated because the fabricated 0 is indistinguishable from a real one. Fix: merged level axes persist in camera modeState exactly like heldInputActions and are served on frames.length === 0 passes; the snapshot is replaced (never merged) on every real pass, so explicit releases (the explicit-zero law: key-bound axes ride every frame as ±1/0) and quiet analog sources clear it naturally. Delta axes are excluded from the snapshot — absence of a frame genuinely means no new motion for them, so the proxy's 0 stays correct. The law: absence of a new input frame reads as the last-sampled level value, never 0 (memory/shared/absence-is-never-a-value.md).
  • Scripted-primitive re-mint is now LEVEL-triggered, killing the stranded-mint class (ledger 1880; dig 33af4d53; closes #1069's admitted residual). #8455's re-mint lane was edge-triggered (fires only on the one applySpec whose detectChangedScripts is non-empty), def-driven (iterates spec defs only), and single-world — while recipe rows are level state. Three lanes carried stale rows past it forever, each a permanent scriptContentSkew derive-mismatch stranding post-edit joiners on terminally invisible geometry (savi's observed split on Pixel Online c37f7ea3: mint-era clients see stale-but-visible meshes, everyone else sees nothing): (a) def-less runtime spawns (api.spawn / children(ctx)) unreachable by the def loop; (b) worlds booting INTO the post-edit spec (detectChangedScripts(undefined, …) is empty by design); (c) TomeScriptedPrimitive is clientAuthWrite:"owned", so a stale backgrounded owner re-uploaded pre-edit recipe+pointer rows over the server's re-mint. The fix is one enforcement funnel (tome/scripted-primitive-remint.ts) for the invariant recipe.scriptHash == buildGeneratorScriptContentHash(currentScripts, recipe.script): applySpec sweeps entitiesWith(TomeScriptedPrimitive) when the scripts identity moved and on a world's first apply; the isolate-swap restore lane sweeps after overlaying captured rows; the upload drain re-judges every adopted recipe row (stale hash ⇒ re-mint from the adopted script/params/seed — the owner's intent survives, the stale mint does not; the deliberate single-writer exception, since the scripts registry is level state only the authority holds current). Cost: one memoized hash compare per row, only on script-move/first applies and recipe-row ingest. The ground-redrape system's local re-mint funnel deduped onto the shared one. warnDeriveMismatch's cause-1 prognosis ("converges when the server re-mints") was provably false for def-less/LWW-stranded rows — the sweep makes it true, and the text now names the mechanism.
  • Orphaned one-shot fx are reaped by the room authority (5.0 debug day #69 class; DUST LINE's measured instance: 15 anchors from two departed sessions pinning ~70 of 256 fx slots). fx-reap ran only on an entity's CURRENT simulator, and a fully departed session's fx has none: the leave path's raw avatar despawn breaks the fx's TomeSpawnedBy chain, the server's owner walk reads null → yields it as place remainder, while every host's walk reads the broken chain as dangling → maybe-owned, never adoptable. No peer's predicate ever claimed it again, so the anchors pinned fx slots forever — a creator hand-wrote an allowlisted janitor script as the backstop.
  • The fix is one deterministic gate, not a scan heuristic: on the client-auth SERVER world (full world knowledge, no AOI — a broken link there is definitive), a runtime fx whose replicated-owner chain is BROKEN (isReplicatedOwnerChainBroken, the same single walk body the host predicates read) joins the ordinary track → derive-completion → reap pipeline with the same fresh conservative clock any successor simulator starts, and counts toward the same runaway cap. No client can be simulating a broken-chain entity (owners walk the same chain and lose it; hosts refuse dangling), so adoption can never cross another writer.
  • Deliberately NOT reap-on-disconnect (engine-principles test 8): departure changes WHO reaps this one engine-owned lifecycle class — self-reaping one-shots with no behavior and no spec entry, whose lifetime fx-reap already owns — never whether or when it would have reaped. Looping/scripted/spec-backed orphans are untouched (the cap remains their backstop); general departed-ownership reversion (ledger 671 / #7414) stays separate work.
  • Pins (fx-reap.test.ts orphan-adoption suite, red-first): a departed client's finite one-shot reaps on the authority at completion + grace from adoption (formerly leaked forever — the red run pinned it surviving 10k+ ticks); a CONNECTED owner's identical fx is never adopted however long it lives; an adopted LOOPING orphan is never lifetime-reaped; a connected client's own fx still reaps on its own simulator; the existing clean-unowned place-remainder pin (host reaps, server abstains) stays green.
  • playSound(clip, { mode: "restart" }) — the Pokemon-cry law (ledger 1705, dig 0f49d653). A new play of the same clip from the same emitter REPLACES the still-playing copy instead of stacking: the client sweep despawns the emitter's live same-clip one-shot voices before minting the new one, and the teardown rides the same cull fade the governor's steal uses (VOICE_CULL_FADE_SEC, 50ms) under the fresh attack transient — no click. The sweep runs AFTER the per-clip governor so a within-floor (~30ms) retrigger drops the new play and the standing voice keeps ringing; replace-then-drop would go silent. Scoped to (emitter, clip): other emitters' voices and the emitter's other clips are untouched. Anything other than "restart" — including the explicit default "overlap" — lowers to the absent wire field. Loops ignore mode (a loop is a keyed channel; there is nothing to replace). Previously this was inexpressible in ObjectAPI (~3 lines in raw WebAudio): repeats summed toward ~8× loudness and then the 8-cap steal-oldest broke sustained triggers — "louder and louder until it breaks".
  • One-shots are stoppable (ledger 1706). playSound minted an id only for loops or onSoundEnd behaviors and returned "" otherwise, and the client stopSound consulted the loop registry only — a one-shot voice was unstoppable by construction, so every script clamp built on stopSound(id) was dead code that reported success (the enfeul specimen published on one). Now every play mints the deterministic handle (unconditional mint = TomeIdSeq lockstep by construction on server and predicted client), the id rides the wire, the one-shot registry records soundId + source entity per voice, and the client stopSound falls through from the loop registry to the one-shot registry. Unknown ids stay a no-op.
  • Pooled-id recycle hazard closed for the new teardown lanes. The restart sweep and one-shot stop despawn voices whose renderer teardown is still in flight, keyed by entity id — releasing the pooled id there recycles it straight into the next minted voice, and the in-flight completion would reap the fresh voice (reproduced in the red run: the replacement voice died to its predecessor's completion). Those lanes hold the id out of the pool; the completion drain (cleanupCompletedSoundEntities) returns it once the teardown reports, and a missed completion falls back to minting, never to a collision (the pool's own law).
  • Poke convergence — the stale-latest-read strand closed (spec-delta continuity F2, design 67870fb4 §3; the 1537 family's server face). Kiln's save fanout pokes /rooms/{id}/update with the dbVersion it just committed; the room answers by fetching kiln LATEST and applying behind monotonic gates. Nothing compared the FETCHED version against the POKED one — so a latest read that lost a race with the commit (replica lag) skipped-or-applied old content and returned 200 while the room silently stayed behind until the next unrelated save: a dead write with a receipt, structurally invisible to the spec-skew heartbeat because the server head and every connected client AGREE on the stale head.
  • The rule: fetched < poked is a provably stale read, never truth — kiln commits before the fanout ever fires, and dev latest is append-only (revert CAS-publishes a NEW head). applyTomeSpec now tracks the highest poke-named version as a convergence floor and re-arms the existing frozen-stamp self-fetch lane (the same bounded-backoff fetch a self-authored gap uses, ledger #352) each stale round, so the 200 means "applied or converging", never "read whatever was lying around". The apply itself is untouched: the fetched content still flows through the ledger 1268/#352 echo-dedupe and stale/self-echo gates byte-identically; the floor only decides whether a follow-up fetch is owed.
  • Bounded stand-down, loud: on the live lane a rollback publish can legitimately move latest backward (game_publishes resolves by published_at, not by version), so a claim that never converges stands down with a warn after 8 stale rounds (~90s of 1s→30s backoff) instead of retrying forever. updateSlug-pinned rooms never track — their fetch pins a published version by design, so "latest < poked" there is the pin working.
  • Plumbing (the design's verify-first item, confirmed): the DO/container shell passed the poke's dbVersion into ApplySpecRequest all along, but EngineHostCore.applySpec dropped it before applyTomeSpec on the ambient tier. It now rides into ApplyTomeSpecOptions.dbVersion. On loopback (workerd DO) hosts the primed spec already carries this exact version, so the comparison is a tautology there — the shell-side fetch's own stale-read exposure on that tier (dispatchUpdate stamps payload.dbVersion ?? fetched.version) is a named sibling seam, not covered by this change.
  • Relay-tier head beacon (spec-delta continuity F3, design 67870fb4 §3 — ledger 987's invariant extended to the relay tier, ends-only). On the relay tier a save rides as one edge-triggered SPEC frame; if that one delivery died (a swallowed fanout send, a seq hole handleSequenced consumes silently, a single-occupant room with no peer to hear anything from), the client stayed one version behind until the next unrelated save — F1 (#11585) chases a fetch it KNOWS about forever, but nothing re-stated a head the client never heard. Now the relay re-states its own durable meta on every heartbeat echo: relay-do.ts stamps journal.getCheckpoint().pointer onto the echo (zero payload parsing, zero decisions, one field — the dumb-pipe law holds; a never-checkpointed room echoes the exact pre-F3 shape). Client-side, the session surfaces the pointer verbatim (onHeartbeatPointer, wire-layer only) and the join coordinator parses it with the existing parseRelaySpecPointer, compares the named dbVersion to the adopted head, and — only when behind, only once the world is baselined — arms the SAME F1 fetch loop a sequenced SPEC frame would (armSpecClaim, the one shared door; never a parallel retry system). Because savi's spec lane checkpoints on every save, the pointer is fresh-versioned on exactly the live-edit rooms the strand class lives in: a dead SPEC frame now decays into ≤ one 2s beat + one bounded fetch of staleness. The host-tick peer-gossip hardening the design names stays deferred (not load-bearing).
  • Inert by construction everywhere else (never worse than today): absent pointers (old relays, never-checkpointed rooms), latest pointers (dbVersion-free by design), unparseable/legacy pointer shapes, versions at-or-behind the adopted head, and mid-bootstrap arrivals (the join machinery owns convergence until baseline) all do exactly nothing. Repeated beats during an armed backoff hold F1's ladder floor — a dead edge sees the 48s-capped cadence, never a 2s hammer — and a newer SPEC frame supersedes a beacon-armed claim instantly, while an older beacon never downgrades a pending claim. A wrong-typed pointer decodes as absent (lenient like welcome.connected): the beacon is advisory and the heartbeat is the liveness signal, so a malformed rider never condemns a socket.
  • Pins (red-first — on the pre-F3 code the wire decoder drops the field, so the core test died with the head stuck at 9): relay-join.test.ts F3 describe drives every echo through the REAL wire decoder — behind-by-one arms the loop and adopts within the beat, loud once per armed claim; the empty-world single-occupant fresh room adopts the first save named by a beacon (head null counts as behind a concrete version); controls pin pointer-less/latest/at-head/behind-head/unparseable beacons byte-identical on the event stream. relay-wire-session.test.ts pins the additive decode + the hook surface with bookkeeping identical for bare and stamped echoes. heartbeat-head-beacon.workerd-test.ts pins the real DO: bare pre-F3 echo before any checkpoint, the accepted marker's pointer re-stated verbatim to EVERY socket (checkpointer or not), and the beacon tracking checkpoint advances.
  • A known spec hole is never abandoned (design 67870fb4 §3 F1 — the nexuiz strand's codified door). On the relay tier a save rides as a sequenced SPEC frame naming a dbVersion; the client fetches that version via the edge. The fetch ladder was 3 attempts with 1s/2s backoff, then a permanent give-up — "the world stays on its current spec until the next save or rejoin" — so a ~3s edge/store hiccup left an ACTIVE client one version behind indefinitely (7 minutes in the field, healed only by the next unrelated save). Worse, a positive-204 gave up after ONE attempt: the store's "that version does not exist" was trusted over the durable sequenced claim that it does — the frame is proof-of-existence (the writer saved, acked, checkpointed); the store read is the lagging party. relay-join.ts now chases the hole with the same withheld-content shape the kernel tier ships (tome/spec-resync.ts — doubling backoff to a 48s cap, forever, loud at each miss), 204s included. pendingSpecUpdate stays non-null through the flight as the loop's liveness truth, so every async resume point re-validates against live state: a newer version superseding cancels the armed wait immediately (a fresh save never queues behind a dead version's 48s timer — host.schedule has no cancel, so a bumped token deadens the old timer), the room reset kills the loop, and the loop survives socket bounces (the hole belongs to the room's spec lineage; a resume re-delivers nothing whose seq was already seen, so the loop is the only healer there). One pending fetch at a time, exactly as before. The one remaining genuine give-up: a realm with no ctx/variant coordinates structurally cannot fetch (unchanged, warns once).
  • Pins (relay-join.test.ts, red-first — the red run died at 3 attempts / 1 attempt for 204 verbatim): a fault retries past 3 on the doubling ladder 1s → 2s → 4s → 8s → 16s → 32s → 48s (cap) and adopts when the fetch lands, loud at every miss, never a failJoin; a 204 for a frame-named version retries the same way and adopts when the store catches up (join-time 204 semantics untouched — pointer/fresh-room pins all green); a newer version cancels the older armed wait and fetches NOW, dead timer inert; the loop heals across a socket bounce; happy path, burst coalescing, replace-at-head triad, and replay coverage byte-identical (39 pre-existing tests unchanged and green).
  • Flatten-along-spline: kind: "road" marks carry a spline and project the whole road footprint from that one curve (r-7e87c657, ledger 1148's shape). spline: [[x, y, z], …] control points resolve through a new 3D centripetal Catmull-Rom sampler (sampleCentripetalSpline3D, the 2D river sampler's sibling — interpolating, cusp-free, curvature-adaptive, the same law #10898's drape crests ride) into one dense centerline + height profile per mark, and every consumer measures against it: terrain FLATTENS to the curve's authored Y profile across the corridor (width, then a falloff shoulder blending back to natural ground — smoothstep, default max(2, width/2)); material/edgeMaterial PAINT the same corridor (unchanged road edge-band law, now curve-following); and object scatter CLEARS off corridor+shoulder (clearScatter: false opts out — honored by legacy roads too). Roads apply after the water kinds in mark order, so the authored curve is authoritative where it crosses a carve (causeway, not trench). Legacy points roads: never move terrain, pinned paint weights, no behavior change (controls in marks-road-spline.test.ts); path/area flatten marks resolve byte-identically (shared profile sampler refactor is arithmetic-identical). Schema: TerrainRoadMarkSchema gains spline/falloff/clearScatter, points+material go optional; api-reference/heightmap-terrain docs regenerated from the schema.
  • Schema-hash connect-time comparison — the registry-skew ghost class closed (#11216 §schema; request wf-a43d8b69). A client connecting with a stale/skewed component-schema registry used to read as a GHOST: entities exist server-side but deserialize wrong/empty client-side (unregistered names drop row-by-row at the shared apply core; changed encoders read garbage), and #11216's dig verified no client↔server schema comparison existed anywhere on the join path. Now the client declares its registry's content hash (ComponentRegistry.schemaHash() — already sorted by name, replicated components only, wire-contract fields only) as ?schemaHash= on the connect URL at every dial (the hostEligible pattern: URL → SocketAttachment → connection.attach, surviving hibernation restore and isolate-swap reattach), and the server's netcode attach compares it against its live registry.
  • Mismatch is a NAMED outcome on both sides, accept-with-warning: server netcode.schema_skew warn log (roomId/clientId/both hashes, deduped per socket + hash pair so restores/swap reattaches don't spam while every real reconnect re-fires) + a schema.skew control frame to the client, which console-warns both hashes with the reload recovery and reports the allowlisted netcode-schema-skew engine-diagnostic — so the fact lands in Savi's getLogs (log-only, no DM: skewed joins during deploy windows are planned churn, the renderer-degraded-adapter #8779 lesson). The behavior ladder's shipped rung is accept-with-warning because the hash is opaque content evidence: it proves the replicated wire contract differs but carries no direction (which side is older) and no compatibility grade, so a hard refuse would kick sessions a benign additive skew leaves playable AND reload-loop the server-is-stale direction (a reload fetches CURRENT bundles, which re-skew against a stale long-lived room). The one direction a reload provably heals — client-stale COLD boots — already hard-refuses with reload copy at cf-edge's stale-hash 409 (ROOM_ENGINE_STALE_CONDITION).
  • Named residuals: a pre-mount first dial carries no declaration (the registry doesn't exist yet client-side; every post-mount reconnect declares — and the deploy-window skew class reconnects by definition); relay tier is registry-less (peer↔peer skew is a different seam); post-attach server hot-reload skew is compared at the next (re)attach, not mid-session.
  • Singleplayer dbVersion-skew watchdog (spec-delta continuity F4, design 67870fb4 §3 — the K2 strand, last leg of the program). The ledger-987 skew lane is deliberately inert in singleplayer: revision counters are minted independently per realm (ledger #718), so getLocalSpecHead answers null there and a dead control-lane spec push — a Savi save the room applied whose tome.spec.push never reached the authority — had NO detector (the withheld-content rail, #7613/ledger 1825, only fires for pushes that arrive and are gated). The heartbeat already reaches suspended singleplayer connections; it now carries enough to audit: noteSingleplayerSpecSkew (room-runtime.ts) compares the beat's claim on the two axes both realms share — kiln dbVersion and structural content hash — and arms ONLY when the server is strictly ahead on dbVersion WITH mismatched content. Sustained past the same 10s window, the episode is handed to the EXISTING withheld-content resync backoff (requestWithheldContentSpecResync — its 48s-capped doubling floor, server-side kiln-lineage adjudication, and #718 admission gates all unchanged; spec-resync.ts is untouched by this PR), never a projection reset — the singleplayer projection is suspended by design. The answer adopts through the ordinary dbVersion gate and the strand decays into ≤ one beat + one bounded fetch.
  • Why the compare is dbVersion AND hash, not dbVersion alone (the 987/#718 inertness audit this leg was budgeted for): dbVersion-behind with EQUAL content is the healthy steady state of every singleplayer session — the room persists this client's own forwarded writes and stamps the new version with no push (noteAuthoredDbVersion), so a bare dbVersion compare would fire on every idle world after every autosave, turning the persist cadence into a permanent kiln-consult + full-doc-fetch cadence. Hash parity names that state silent. The armed clock carries the 987 armed-evidence discipline transposed onto dbVersion: delivery proof is the ARMED version landing (busy worlds — perpetually one persist behind, adopts jumping to emit-time heads — clear within a beat and never sustain), evaluated only at post-apply instants (thaw bursts acquit first).
  • Inert by construction on every case the current inertness deliberately protects: multiplayer worlds — including Suspended hidden tabs whose withheld deltas are healthy — report a null singleplayer head (readSingleplayerSpecSkewHead is getLocalSpecHead's exact mirror; the 987/F3 lanes own those tiers and their code paths are untouched); revision divergence of any size or direction never consults this lane; content mismatch AT the dbVersion tie stays the withheld DROP rail's jurisdiction (its pushes arrive — arrival is its trigger); mid-bootstrap (no world, no baseline, no dbVersion stamp) and old servers (no beat head, no dbVersion, no hash) are unknowable and never skew; socket death discards the clock; relay-tier singleplayer never records a kernel beat head at all (F3's pointer beacon owns that tier).
  • Pins (red-first — on pre-F4 code the lane is inert, so the strand test dies with zero fires): room-runtime.test.ts F4 describe — the K2 strand arms/sustains/fires/converges; controls pin the self-persist stamp lag (60s behind-with-equal-content, silent), revision divergence at the tie (silent), mid-bootstrap and protocol-additivity disarms, the busy-world armed-delivery ladder (60s, zero fires), thaw acquittal, socket-scoped evidence, and the multiplayer lane firing its 987 heal with both hooks installed while this lane stays dark. runtime-worker.spec-push.test.ts pins the head reader's null cases; netcode-upload-echo.test.ts pins the beat carrying {revision, dbVersion, hash}.
  • Wrap/repeat decoupled from plate-class (r-1f85d7ec, plan p-35fbe727 item 3). repeatX === true reclassified a world sprite to plate-class (sprite-appearance-rules.ts + the lume copy in sprite-logic.ts), so asking for a repeating texture silently bought unlit + unbatched + camera U-recenter. Both classifiers stop consulting repeatX; plate-class remains parallax + the moodboard plate grammar (backdrop-*/strip-*/floor-*). Aligned with #11486's move one surface up: wrap is sampler address state, carried by the texture lease, never a class.
  • spriteWrapModes(sprite) is the one wrap rule (sprite-logic.ts): U wraps for repeatX or an authored uvRect overflowing U; V wraps for an authored uvRect overflowing V; everything else clamps. Three consumers, by construction in agreement: the texture lease options (spriteTextureOptions), the batch key (a |wrap: segment appended only when wrapping — clamp keys stay byte-identical), and the texel-AA snap exemption (the AA sampler is linear-clamp; substituting it would stomp tiling).
  • The resurrected three-era strip machinery dies again (sprites.ts): repeatRecenterX (whole-tile camera recenter) and the world-sprite 3×-quad span (1 + 2·tilesX in packVisual and the bounds path) are deleted; getSpriteBatchKey no longer nulls on repeatX. Camera-attached strips are untouched — resolveCameraAttachedPlacement still spans the viewport and scrolls the UV phase.
  • Sim raycast parity (tome/api/sprite-raycast.ts): a repeating world sprite has an observer-independent quad again, so the lane hits it — what the player sees is what the ray hits. Parallax and camera-attach exclusions unchanged.
  • Torn-parent heal goes LEVEL-triggered, closing the two proven heal holes of the marionette-tear class (ledger 1884; dig 58262db3). Authored parented children were observed losing their live TomeParent mid-session and freezing at stale world coordinates while their roots kept moving — whole actors dismembered at one instant, spec rows healthy the whole time. The reattach pass (rebindExplicitParentObjects) provably heals this — but it was edge-triggered, running only inside a content-CHANGING applySpec, which left two holes: (1) the delta-0 short-circuit (interpreter.ts) skipped it entirely, so every join/reload/reset re-applying the SAME revision left a torn room torn forever (field receipt: a creator's client reloaded and the room was still torn two minutes later); (2) a fresh client's first spec apply ran before its AOI rows existed (hasEntity false ⇒ silent skip), so entities landed torn from replicated rows with nothing left to re-run at that revision. The fix: a spec-entity index (TornParentHealIndexResource: runtime child id → authored def, derived by the exact rebind derivation, cached against doc identity + place-instance registry content) and one sweep over it (sweepTornAuthoredParents — O(authored parented rows), heals missing/dangling edges only). The delta-0 short-circuit now runs the sweep (precedent: it already ran the spline-orphan sweep — both are state-dependent passes no content compare can veto), and a new simulator-resident system (tome/torn-parent-heal, every 30 ticks, beside lifetime-reap) enforces the invariant between applies on the world that simulates the entity, so a host-side tear heals at its home and uploads through the owned component lane. A heal fires tome.parent_heal.reattached — loud while the tear's origin remover is still hunted (the O1 mask-fold apply-diff lane is disconfirmed by a committed probe; origin fix lands separately on its receipt).
  • Wheel over UI never reaches world input — the same silence presses already get (ledger 1883, savi-filed f98584b1). A wheel over a god-mode/creator-tab panel scrolled the panel AND zoomed the world: the frame forwards every wheel with its overUi verdict attached (input-forward.ts, capture-phase document listener — authored stopPropagation can never suppress it), but the verdict died in both host sinks of routeWheel. Raw-capture lane: ForwardedWheelPass carried no overUi field, so the sole gate was slot geometry (always passes fullscreen) and the delta rode the input ring → mouseWheelDy → any spec binding wheelY (NEON APEX's zoomIn + camera-chase = the felt zoom). Transport lane: handleWheel was gated only by slotBlocksWorldPress, a press-shaped predicate whose !pointer.overUi press-exemption (correct for presses — press consumers read the paired overUi flag) wrongly admits over-UI wheels, whose delta has no such pairing. Fix at the boundary, since authored axes structurally can't check: ForwardedWheelPass gains overUi and injectForwardedWheel drops over-UI wheels before the ring (mirroring pressMouseButtonIntent's overUi silence); the transport handleWheel drops the wheelDelta whenever the event's own verdict is overUi, regardless of slot state (freshness post still ships, mirroring the slot-blocked branch); and the native raw-capture wheel listener gets the same data-input-capture hit test (canon §3 one-code-path law — native overlay and realm agree). The god dolly's own sim-side overUi re-check (scrollConsumedByAuthoring) was the one protected consumer; now every consumer behaves like it. Presses are byte-identical: slotBlocksWorldPress itself is untouched.
  • Lazy view materialization (ledger 1894 leg a). buildObjectView ran all ~40 propertyGetters per api.getObject call — spec locates and deep clones for properties the calling script never read, per entity per tick (dig c961d106: 17.2% of MechaBlade's sim-worker busy CPU inside this loop, GC sawtooth 65→96MB from clone garbage, worst tick 287ms). The view's properties bag is now built on first access, and each key is an enumerable accessor that memoizes into an ordinary data property on first read. Key set/order, spread, Object.keys, JSON, structuredClone, local assignment/delete, and per-view value stability are all preserved (pinned in object-view-read-path.test.ts). Script-transaction overlay worlds keep the eager snapshot (their staging worlds die at commit).
  • Spec object index (leg b). findSpecObject ran a findIndex linear scan over every place's objects array per call — the top JS leaf in the trace, reached per read through the spline/blueprint/brush getters. Now an id→row index per spec identity (WeakMap<GameSpec, …>, the SPRITE_DEF_INDEX shape), revalidated per lookup by a freshness walk (places-record identity, per-place array identity AND length) that mechanically catches the one in-tree in-place writer (ensureSpecEntryForSpawn's spawn-stub push) plus place add/delete/REPLACE under an unchanged root; hits additionally validate the row's id and fall back to the verbatim linear reference on any mismatch. Precedence semantics (hint place → entries order → legacy scope → namespaced-id fallback, first row wins on duplicates) are pinned test-by-test.
  • Cheaper isolated clones (leg c). The model getter's per-read structuredClone(parts) becomes cloneSpecValue (the plain-data cloner the spec getters already use). Reads stay per-read copies rather than shared frozen views — deliberately: the mutate-then-write-back round trip (const m = …model; m.parts.X.visible = false; setObjectProperty('model', m)) is a documented creator contract (ledger #735), and sharing structure with the store would make the write-site equals gate compare the mutation against itself and silently drop it. Write semantics byte-identical; isolation pinned by test.
  • Microbench (scripts/bench-object-read.ts, 600 authored objects + 120 runtime-spawned, bun 1.3.14): getObject+position/state 14.31µs → 1.05µs (13.6×); getObject+read model 18.10µs → 9.19µs (2.0×); getObject on runtime-spawned ids (the full-scan miss path) 18.24µs → 1.00µs (18.2×).
  • Unknown playSound option KEYS warn once (dig ec4cb85d — the sfx-zoo fight). Savi invented a soundId: option and the engine swallowed it silently; her stop calls then aimed at a handle that never existed — three debugging rounds, zero engine feedback. The engine already warned on given-but-unusable VALUES of known keys (bus/priority/maxDistance); the unknown-KEY class now teaches through the same audio-verb throttle: once per entity, value-free dedupe key, the offending keys and the valid key set ride the first message. soundId among the unknowns names the true lane outright (playSound RETURNS its handle). Legacy gain/spatial spellings are read, so they never warn — but they're not advertised in the teaching list either.
  • stopSound(id) with a never-minted id warns once; finished handles stay silent. Every handle playSound has ever minted carries the uniqueId("sound") shape; an id without it (and absent from the loop registry) was never a handle in any engine version — the typo/invented-handle tell, and exactly the id shape the sfx-zoo stops died on. A MINTED handle whose sound already finished stays silent: finished is normal, and the server keeps no one-shot liveness record to tell live from done. No-op semantics unchanged — the stop event still rides, downstream stays tolerant. The loop registry's silent-tolerance comment now points at the verb-site teach (the registry write also ingests relayed events, where a warn would be ops noise).

Engine v5.2.10

Released July 30, 2026

  • Gear attached to a character's bones (armor, hats, held items) now moves welded to the body — no more trailing a beat behind when you run or spin. No script changes needed; interpolation: null workarounds can stay or go, they no longer matter.
  • Editing a live effect now just works: save the .fx.js and every running emitter picks up the new program — no more destroy-and-respawn, no filename rotation. visible: false actually hides an object's particles now (and your own torch flame stops rendering inside your first-person camera), and fx.paused: true keeps suspending the deck like before.
  • Fast vehicles and characters no longer hop on flat ground. The engine used to briefly lift the physics body ~25 cm at speed and float it back down — cars read as bouncing, and collision/traction wobbled with it. The body now stays glued at any speed; slopes, stairs, and kerbs still climb exactly as before.
  • If your game's input map carries a binding that can't work — a misspelled key name, an axis declared with no source, an engine-owned axis name — Savi now sees exactly which binding is broken and why the moment it's written, instead of your controls silently half-working until someone debugs it. Working bindings are never affected by a broken one.
  • Look scripts can now tile textures: ctx.texture("/cdn/paper-fibre.png", { wrap: "repeat" }) repeats past the texture's edge instead of smearing the last row of pixels into streaks across the screen. "mirror" reflects instead; offscreen targets take the same option.
  • You can design your phone controls now: twin sticks, gas-and-brake pedals, drag-to-aim buttons, touch pads — and swap layouts per player mid-game (driving vs on foot). Savi can finally read exactly what's on a phone screen, and the engine tells her when a button didn't fit instead of quietly hiding it.
  • Buttons in custom game UI can be held down now (sprint, charge shots), and hand-built on-screen sticks stop snapping back under your thumb.
  • Fixed two ways a 3D model could silently render nothing: a model file the device can't decompress now reports a clear load failure (instead of an invisible "successful" load), and a model that's taking unusually long to generate now says so — to you and to Savi — instead of leaving empty floor with no explanation.
  • NPCs told to walk somewhere while facing something — moveTo(target, { face: player.id }) — now actually keep facing it the whole way: backing away while aiming, strafing a range band, or sidestepping around obstacles no longer snaps their gaze to wherever their feet point. Drop the face option and they look where they're going again.
  • When a game looks frozen because its hosting player's tab is hidden (solo rooms pause while you're away — that's by design), Savi can now see and explain exactly that instead of chasing a phantom engine bug.
  • Spawned models show their concept art in seconds now: while the 3D model is still generating, the spot where it will land shows a billboard of its 2D image (the one Savi's generation mints almost immediately) instead of just the loading swirl — and the real model replaces it the moment it finishes cooking.
  • Fixed a silent failure where, in worlds with very many uniquely-shaped generated objects, newly added ones could stop appearing entirely — with no error anywhere — once a rendering batch filled up. The engine now tells Savi exactly which batch filled and what frees space, the moment it happens.
  • Worlds with very many uniquely-shaped generated objects no longer hit an invisible ceiling where newly built things stop appearing: the rendering batches now grow with the world (up to what the player's GPU can actually hold, which is several times the old limit). If a device's true limit is ever reached, it still announces itself instead of failing silently.
  • Savi can now actually see your game's UI when she checks it — before, a technical wall meant her UI checks always came back empty, and she could mistakenly report your HUD as broken (and prescribe reloads) while you were playing through it just fine. Her reads now also come with the engine's own delivery receipts, so false alarms about invisible UI stop at the source.
  • On a shaky connection, multiplayer games no longer stutter backwards: NPC timers, animations driven by state clocks, and moving objects run by your game's scripts keep advancing smoothly instead of repeatedly snapping back a half-second every time the connection strains.
  • On a bad connection that goes one-way (you can still see the world but the game stops hearing you), the server no longer yanks you back to your landing spot every ~30 seconds forever — after a few minutes of provable silence it tells your game to reconnect once, you land back in your own body, and play continues.
  • Fixed a bug where nudging just one rotation axis (like rotation.roll) on an object could silently do nothing — the object stayed frozen at its old angle. Single-axis rotation writes now turn the object, keeping the axes you didn't touch.
  • Savi can finally see what she builds: her inspection camera now carries your world's look and bloom (godray scripts, grades, vignettes — the same pixels you see), and she can read exactly where your sun is instead of guessing from shadows. Look work stops being "a bet until you turn around and look."
  • If a scripted object's geometry script produces an empty mesh (nothing to draw), Savi now finds out immediately — the error log names the object and the script, and asking her to look at the object tells her the geometry came up empty instead of guessing it's still loading.
  • Stepping off a moving boat onto a dock (or any ground) now cleanly ends the ride — no more being dragged along the pier at boat speed. Riders also survive server host changes and tab-away/return without getting glued to the boat.
  • Moving platforms just work now: stand on a boat, elevator, or any moving object and you ride it — walk around on deck at normal speed while it moves, jump and you keep its momentum, step off and you're free. No follow scripts needed, and the ride is glued at render cadence (no more shaking while the deck moves).
  • Fixed a bug where long-running rooms could get stuck naming every new thing with the exact same id — weapons, projectiles, and effects would silently replace each other and cleanup would miss. Affected rooms heal themselves the moment they mint their next id; no reset needed.

technical notes

  • Bone-attached children join the render-composed rail (ledger 1860 — tucker's armor trailing its wearer). A tome/parent op carrying attachment.bone used to parse to parentId: null, transformAlreadyPresented: true: excluded from the renderer's parent-relative composed-child rings and drawn from raw wire rows that the SIM composed through the cross-worker bone-pose feedback — the bone's world transform from the renderer's LAST displayed frame, plus a channel hop. Net: worn gear = the body's drawn pose 1–2 render frames ago, trailing by speed (measured 0.39 units at a 0.25 u/tick sprint in the new harness). Now bone children keep their parent edge and compose RENDERER-side at draw time: parent's displayed pose ⊗ live bone local ⊗ authored offset — the live rig providers already register renderer-side (lume/models/skinned-character.ts), so the composition is same-frame with zero feedback round-trip. This is #11535's stand-rider rail (drawn composition against the parent's displayed pose at render cadence) extended to bone anchors.
  • The drawn chain, not the physics chain: the new writeDrawnBoneTransform provider leg runs the base through the visual's own trailing chain (local offset ⊗ geomScale + ground-alignment lift — composeVisualWorld's math), so the bone lands where the body actually draws. The existing writeBoneTransform base leg stays deliberately lift-free (ragdoll seeds pin that contract).
  • #7350 stays dead, structurally: the pass-through rail (transformAlreadyPresented + its sync path) is deleted, and bone children never seed a snapshot ring — the wire rows CANNOT be re-interpolated (the double-delay #7350 cured has no code path left). Pinned from both directions: corrupt the feedback channel and the drawn pose stays welded to the live rig while the sim rows dutifully carry the corruption.
  • The feedback channel keeps serving the sim, unchanged: hierarchy-render-solve still composes the child's World* rows from the SAB feedback every renderPrep (gameplay reads, script queries, ecs-sync). Those rows draw only as the fallback while no live rig is registered (model loading / horde-batched parent — the ledger-#308 seed baseline), exactly the old first-frames behavior.
  • Named gaps: a pivoted DIRECT bone child draws unfolded (the pivot fold needs bounds the op stream doesn't carry, and anchor inference can't serve here — a direct bone child's authoritative rows are feedback-stale, so the inferred anchor would bake in the trail); children NESTED under a bone child infer normally. Bone children of an interpolation-disabled or record-less parent keep the raw-row fallback.
  • Running fx decks adopt every recompile — live .fx.js content edits included (ledger 1834 face 1). The renderer-side diffs (lume particles store, particle service, both backends) adopted a program only when FxCompiledProgram.sourceVersion (= FxEmitter.version) moved — but a live script-content save recompiles under an UNCHANGED emitter version, so a running deck kept executing the original program until the ref itself was rotated to a new filename ("the effect system reloads by filename, not by file contents" — savi taught filename-rotation as the workaround, baking ref churn into specs). FxCompiledProgram now carries compileSeq, a per-entity monotonic counter minted by tome/fx-compile on every program write, and every downstream diff keys on it: any recompile — params write, script edit, lib edit, cache-epoch reset — reaches the running deck by construction. The recompile-invisible-downstream class is unrepresentable now, not patched per trigger.
  • fx decks honor visibility (ledger 1834 second face, incident b62bd296 THE VOW). The particle path never read draw/visibility: properties.visible = false hid the model while the deck kept emitting (mist visibly rendering while visible === false; same leak under the first-person body hide, the editor-only courtesy hide, and the foreign camera-attach gate — all DrawVisibilityOverride writers). tome/fx-compile now resolves effective visibility the way the mesh path does (client-local override wins over authored; layerMask 0 = hidden) and a hidden emitter drops its compiled program exactly like paused: true — deck gone next frame, unhide recompiles from scratch (decision 11; non-zero layer masks stay visible). fx.paused and api.destroy() behavior unchanged and pinned.
  • Ground-follow lift gate (ledger 1853, tiger's car-bump): a kinematic character controller driven across a FLAT heightfield at race speed popped up to ~0.20 m in one tick and floated back down over the next several — track flat, zero script y-writes, autostep disabled. Mechanism (measured, ground-follow-lift.integration.test.ts reproduces it from a plain world): rapier's controller sweep collides with the SHARED edges between coplanar heightfield triangles and parry reports phantom tilted contact normals (n = (-0.28, 0.96, 0) on an all-zero field); collide-and-slide climbs the phantom ramp, and the taller the per-tick sweep the taller the climb — race speed turns a sub-millimeter artifact into a visible hop, and the motor's ground press (-6 m/s) takes ~3 ticks to bring the root back down. The float-back was never a separate mechanism; the up-pop is the whole injection.
  • The fix verifies the output against the world instead of trusting the phantom: when the motor is pressing down (no commanded upward motion) and the computed movement still rises more than 5 mm, down-probes at the landed position — axis, thin lateral offsets, and leading samples out to the shape's full support reach along the motion direction (a rotated car box reaches 2.2 m lengthwise; slope entries and kerb step-ups ride the leading corner) — measure the ground that is really there. A rise the surface backs (slope, step, kerb) commits untouched; a rise into air gets clamped to follow the measured surface, and the grounded verdict rides the committed pose. Probe origins sit INSIDE the shape near the feet, so an overpass above the car can never masquerade as support, and embedded floors stay visible. Rays fire only on rising ticks (≥ 5 mm) — idle, walking, and flat cruising pay nothing.
  • Not fixable at the source, and mantle needs none of this: HeightFieldFlags.FIX_INTERNAL_EDGES (rapier 0.19.3) only corrects the contact-manifold path — measured: controller pops byte-identical with the flag on — so the gate lives at the controller output seam. Mantle's heightfield narrowphase already folds neighbor-triangle normals structurally (narrowphase/static-heightfield.ts edge activity) and never manufactures the ramp.
  • Pins: ground-follow-lift.integration.test.ts — (1) race-speed box on a flat two-chunk heightfield stays glued (pre-fix: +0.2035 m single-tick rise, red; post-fix: ≤ 0.0134 m, within contact noise); (2) a 15 % grade still climbs at race speed (the gate's leading-corner samples keep real hills honest); (3) a 0.2 m kerb still autosteps at driving speed.
  • Per-binding input-map refusals (ledger 1851 residue (a), forensics 3b93f733 — tucker's pirate sloop): the input compile was already per-binding tolerant — a binding that can't resolve is dropped ALONE and its neighbors stay live — but nothing ever SAID so: resolveKeyBinding returned null silently, a case-broken code-shaped name ("Keyw") passed through verbatim as a binding that can never match an ev.code, an engine-internal axis declared in inputs.axes (aimYawSin: {} — the pirate map's exact content) compiled to nothing, and the only diagnostics were console.warns in whatever realm compiled the map. A creator sat pinned at spawn for an afternoon while Savi probed for what one write-time line would have named. New collectInputBindingFaults in input-config.ts shares the exact resolver tables (no drift possible) and emits one fault per broken binding — path, reason, fix, with the valid form suggested when the mistake is spelling case ("Keyw" → did you mean "KeyW"?, "escape" → "Escape"). Two lanes consume its printable form (capped at 12 lines + remainder): patchInputs warns on the mutation-warn rail (Savi's getLogs, at write time, validating the MERGED map so still-broken bindings re-warn on the next write), and spec-sync's control install warns on the client console ring once per distinct authored map (the existing inputs-signature gate) — so a map written by ANY path names itself.
  • What faults, exactly: engine-internal axis declarations (ENGINE_INTERNAL_AXES — never bindable, the declaration does nothing); dead key tokens (silent-drop null resolutions AND verbatim-passthrough tokens whose case-fold matches a known binding); Tab (named reservation); wrong containers (axis keys must be a slash-string, action keys an array — a bare string binds each CHARACTER); unknown mouse/gamepad names with their valid vocabulary; junk activeOn/modifiers; near-miss field names on inert defs (key → keys). What deliberately does NOT fault: any case of a documented shortcut ("arrowup"/"Arrowup" resolve — the pirate map's lowercase arrows always worked), unenumerated code-shaped tokens with no case-fold match ("NumpadComma" — the raw-capture escape hatch), empty ACTION defs (the sendAction UI pattern), empty AXIS defs get a notice teaching their live uses (sendAxis / authored touch sticks) rather than a refusal — declared-but-unbound axes stay declared, and creator metadata beside a live source stays unpoliced.
  • The anti-cliff pin: input-binding-validation.test.ts pins survival (WASD moves and actions fire with three broken axes in the same map; a dead key name disables its one binding alone) and byte-identical compile of the pirate map, so per-binding tolerance is now a contract instead of an accident — input validation can never become all-or-nothing without a red test.
  • Look-script textures gain wrap control — UV>1 no longer forces silent edge-texel smear (THE VOW's screen streaks, ledger 1847: every look-pass ctx.texture() bound a hard-coded clamp-to-edge sampler, so a paper-fibre texture sampled at screenUV × aspect rendered ~2/3 of a 16:9 frame as horizontal/vertical streaks with zero signal). ctx.texture(assetId, { wrap: "clamp" | "repeat" | "mirror" }) and ctx.target(name, { …, wrap }) (riding .previous too) lower to the GPUSampler address mode (clamp-to-edge / repeat / mirror-repeat) — the smallest primitive, no pipeline: the wrap rides the ShadeTextureRef, and the look pass picks the sampler per ref at bind time. Defaults stay clamp everywhere (existing content pixel-identical); the engine-owned frame/depth inputs stay clamped by construction (edge-extension is what screen-space kernels want). Unknown option keys and unknown wrap values throw teaching errors inside look(ctx) — one diagnostic naming the vocabulary, look parks on the copy fallback (the fx-sink invented-keys law).
  • Authored touch surfaces: inputs.touch.surfaces — named, complete TouchSurfaceDef documents (multiple sticks with zone/anchor/quantize, buttons incl. axis pedals and drag-aim with release payload {x, y, magnitude}, absolute/relative pads, one-action world gestures, ordering slots, icons, sizes, roles) — plus inputs.touch.active, a per-player selector script (default export (ctx) => name, worker-evaluated on the god-posture ship pattern; junk/unknown picks park that player to the draft with a teaching fault; oscillation is rate-limited and faulted). Surfaces compile into the same ControlSurfaceSpec the draft and god specs feed deriveLayout; the engine never merges two surfaces.
  • Silent drops died as a class: every clamp/drop/park is a named TouchSurfaceFault (unknown-channel, zone-conflict, button-overflow, stick-suppressed, gesture-folded, …) surfaced through compile errors and getLogs teaching faults; main-side geometry drops post an engine.diagnostic (touch-layout-overflow).
  • The draft is a value: api.resolveControls(surface?) returns the engine's auto-projected draft (no arg / "auto") or a named surface, each with diagnostics. The draft's projection semantics stay byte-stable (pin-tested at reference viewports) — floor, not ceiling.
  • Authored button capacity is geometry, never a fixed cap: deriveLayout fits what safe-area + chrome reservations + 44px thumb floors physically allow and drops loudly from the slot-order end. The draft keeps its cap of 4. Slot is an ordering number; axis-hold's game-path ban and role's god-only scoping died.
  • Runtime: slot-indexed analog SAB (16 slots — twin-stick works), input.axes.touchActive (1 while touch is the live input; desktop trace-free), surface swaps are edges with cancel synthesis, ?touch=1 gives desktop creators a live touch-chrome eye.
  • Tome UI contract fixes: sendAction(name, undefined, { down: boolean }) plumbs the wire's held-button field (routes to setAction — press/release edges, hold bindings included); the god-slider data-dragging morph guard is generalized — any authored element holding an active pointer capture is never morphed mid-drag (hand-rolled UI sticks stop being rebuilt under the finger).
  • A model that cannot decode now says so out loud instead of rendering nothing (ledger 1862, dig e6888eaf). parseGlb/parseGltfJson refuse a file that REQUIRES EXT_meshopt_compression when no meshopt decoder is available (GltfParseError), instead of "succeeding" with every compressed accessor read as zeros — full node/mesh topology, invisible geometry, all load books green (repro: 0/165,246 nonzero positions). Since the v7 quantize+meshopt republish that was every Magic CDN visual model for any client whose wasm init failed. The refusal rides the EXISTING model-load failure machinery — renderer retry ladder, console.error, and the ledger-1025 model-load-failed diagnostic carrying the parse refusal verbatim — never a new lane. Files that merely USE the extension (fallback buffers present) keep the per-bufferView warn-and-zero degrade; healthy loads parse byte-identically (pinned).
  • ParsedGltf.warnings has a consumer (ledger 1862's second face: written since the lume cutover, read by nothing). A model that loads successfully but parsed with warnings — zeroed accessor reads, skipped primitives, unsupported required extensions — now surfaces them once per id on the new allowlisted model-parse-warnings diagnostic (log-only: the load succeeded, so it is why-does-it-look-wrong context in getLogs, never a DM) plus a worker-console warn.
  • A Magic CDN cook loop past five minutes is a named fact, not a silent treadmill (ledger 1025's never-terminal hole, dig e6888eaf: badgerblunts' ten-clip ?animations= bot variants looped 202→cooldown all session with zero Savi-visible signal). 202 answers never escalate attempts by design, so the attempt-5 model-load-failed diagnostic can never fire for a cook that simply never lands. New allowlisted model-generation-slow diagnostic fires once per pending episode when a model is still answering 202 past 5 minutes — deliberately worded NON-terminal (the engine keeps re-checking; the model appears when the cook completes; regenerate/simplify only if it stays stuck).
  • Cooldown-parked models stay visible to getUnreadySceneAssets (the dig's confirmed second gap). Render-on-change stops prepares in a quiet scene, so a model waiting out a retry cooldown aged past the 2s poll-liveness window and vanished from the unready report for most of every cooldown — Savi's captures read "nothing missing" while ten bot variants were mid-cook. A model whose retry is scheduled (finite retryAfter in the future) now counts as scene-referenced until that deadline: the due-retry wake buys a prepare, so a scene that still wants the model re-polls (stamp refreshes) and one that dropped it ages out at the deadline (pinned both ways).
  • moveTo(target, { face }) now holds the face override for the whole move (MechaBlade "regression on untouched code" dig: the option carried faceTargetId in the intent but stamped only the move beat, and resolveYawTarget honors the override only while the FACE beat is fresh — which only the explicit face() verb stamped. Net: the documented option decayed to travel-direction yaw within one beat grace, so "walks toward you facing you" worked only when travel happened to point at the target — empty-yard geometry — and died the moment pathing sidestepped around an obstacle). The fix follows the beat system's own grain: a face option IS a face assertion, so moveTo stamps both the move and face beats (assertNpcIntent now stamps a beat per asserted channel); the override holds exactly as long as the moveTo keeps being re-asserted and releases with it — travel yaw resumes within the same decay grace as everything else. No resolver/system special case.
  • A plain moveTo no longer clobbers a live face() override's fields — same root, second symptom: moveTo's patch used to carry explicit-undefined face keys, so api.face(x) called BEFORE api.moveTo(y) in the same pass was silently wiped (only the face-after-moveTo order worked). moveTo now writes the face channel only when its option asserts it; face() beside a plain moveTo holds in either call order. When both writers assert in one tick, the last call wins (merge-and-stamp semantics, pinned).
  • Sweep result: face was the only intent option gated on a beat its writer never stamped — speed/range/exact/avoid/fleeUntil/wander opts are all honored while their own verb's move beat is fresh, and steer() stamps its own beat. No other silently-decaying option found.
  • Held/unhosted places tell you they're frozen (ledger 1839, dig e6ed67b2 — the ledger-931 pause-legibility pattern extended to host state). In client-hosted multiplayer the server never simulates place content; while a place's seat is successorless-HELD (ledger 1788 — the sole host's tab hidden/silent/mid-reconnect with nobody else to elect) or the place is unhosted-but-occupied, update()/api.on events/interactions/timers are frozen there by design while onSpawn, crons, api.sql, and edits keep working — previously with zero Savi-visible surface (Savi filed the designed freeze as an engine bug four times in one night; ~447 hold episodes across 48 apps in a 4h window is standing weather for solo-occupied rooms). Now: (a) every run_script result during the freeze carries a prepended place "X" is not being simulated right now … note naming the reason, the since-tick, the frozen-vs-alive class split, and the "designed netcode behavior, not an engine fault" verdict; (b) an edit landing in a frozen place writes one getLogs runtime-log line per hold episode (episode-deduped like 931's pause line); (c) api.reportEngineBug incident dumps carry a placeHosts block on the server context — every table row plus hold state (reason, sinceTick), tenure confirmation, and occupancy — so a "update() never fires" filing self-classifies. One truth for all three surfaces: tome/place-sim-status.ts reads the same hold-episode record the assignment system maintains (moved into the cross-system PlaceHostTenureState; zero simulation-behavior change).
  • The placeholder ladder's middle rung is back: a cooking model shows its 2D concept image as a billboard within seconds, then swaps to the model when the cook completes (thread 1785358887). The three-era hologram (extensions/models/placeholder-visuals.ts) crossfaded to the Magic CDN preview image; the lume port (#7602) cut that lane with a TODO(lume-placeholder-previews) and shipped pure-procedural rings — the "swirling circle until it loads completely" regression. The lume placeholder module now resurrects the old logic minus the bubble shell (removed on taste back in ledger 417): collectPlaceholders already derives previewTextureId from the preview metadata poll (preview_image_url is written ~2-3s into a Trellis/Meshy cook, minutes before the GLB), and packPlaceholders now registers that URL with the lume texture service (registration + subscription IS the load demand — the override-map mount pattern), partitions instances procedural-first then one contiguous run per decoded preview texture, and draws each run with its own bind group and a firstInstance offset into the shared instance buffer. The preview's own alpha masks immediately (BiRefNet cuts the background CDN-side); the procedural hologram crossfades out underneath at the three-era 4/s rate. The rung is visual-only: pick volumes (placeholderDrawnSize) and physics are untouched, failed cooks stay procedural red (legibly dead beats pretty), a missing/undecoded/layered preview falls back to today's procedural hologram instance-for-instance, and the model-ready swap releases the texture retain and subscription the frame the placeholder drops.
  • The batch-lane arena-full refusal is loud on every rail (ledger 1833, War Cloud d757e8d9 root). When a primitive batch lane's shared geometry arena hits its cap (LANE_MAX_VERTICES/LANE_MAX_INDICES), every NEW unique shape routed to that lane is skipped by design — the object exists, answers queries, keeps physics/bounds, its bespoke-geometry wait resolves (data was delivered), and it renders NOTHING, permanently. The engine already emitted primitives-lane-arena-full for this, but the code was never allowlisted server-side (the #330 emitted-but-dropped class) and the emitter printed no console line — so the loss was invisible end to end: clean console, clean getLogs, zero stall reports (War Cloud saturated one vertex-PBR lane at exactly 1,048,576 reserved vertices across 2,664 split-slot registrations from ~440 scripted bodies; everything refused after the cap — the creator's viaduct among them — was silently invisible for two sessions and ~100 rebuild attempts). Now: the diagnostic is allowlisted (full-body DM — ACTION-classified with the #297 consent rule, the outline/shadow-slots shape), it prints a server-console breadcrumb (client-side emitter; the stdout line is what makes arena exhaustions DD-countable fleet-wide), and the emitter warns on the worker console (once per lane) so client dumps carry the refusal.
  • Batch-lane geometry arenas grow on demand — the fixed 1M-vertex wall is gone (ledger 1833, the capacity half; the silence half landed as primitives-lane-arena-full loud rails). A primitive batch lane's shared vertex/index arena was admission-capped at a fixed LANE_MAX_VERTICES = 1<<20 / LANE_MAX_INDICES = 1<<21, even though the arena's whole growth stack already existed (pow2 CPU mirrors + GPU buffer recreation that preserves flushed content with a buffer-to-buffer copy — mobile memory diet phase 2a). War Cloud held one vertex-PBR lane at exactly the cap (2,664 registrations, 702,604 live vertices, ~33% pow2 reservation padding), so every later unique shape — the creator's viaduct among them — was refused at the batch seat and rendered nothing, forever. The fixed caps are deleted: admission now checks a device-derived ceiling (laneArenaCeilings) — min(maxStorageBufferBindingSize, maxBufferSize) over the lane's vertex stride (pulled lanes bind the whole arena as ONE storage buffer, so bindability is the bound, not creatability), maxBufferSize/4 for the u32 index arena, and an absolute 2^24 vertex cap (instance-param float 21 carries each slot's vertex-arena base as f32; integers stay exact through 2^24). Under the ceiling the arena simply grows on the existing ladder; before a lane has seen its device, the WebGPU spec-default limits are the floor (any device can bind what they admit), so cold-boot bursts grow too. On a desktop 512 MiB grant a vertex-PBR lane now tops out ~7.07M vertices (~6.7× the old wall); at the TRUE ceiling the loud primitives-lane-arena-full refusal (DM + breadcrumb + console) fires exactly as before, now carrying used/max vertex+index counts. Growth cost is a one-time hitch per doubling (a lane doubles at most a handful of times in its life): one GPU-side copy of the old arena (~76 MB at the 1M-vertex boundary — sub-millisecond at VRAM bandwidths, and it replaces what would otherwise be a CPU re-upload) plus one prepare's worth of mirror re-uploads and bundle re-records. A permanent one-frame hitch per size class beats permanently invisible geometry. The pow2 reservation padding (~33% burn in the specimen) is deliberately untouched — growth, not packing, is the fix.
  • Authored UI is now READABLE from the tool side — the realm-aware read relay (ledger 1845 fix b, the structural cut behind the c13632c4 phantom-blackout family). Since the authored-realm isolation cutover, ui.js DOM renders inside a sandboxed opaque-origin iframe, so run_ui_script's host-document reads structurally cannot see it: every probe of the taught addresses returned empty and healthy HUDs got filed as "never paints" while creators played through them. New client RPC read_authored_ui (contract read-authored-ui/1) crosses the frame broker into the realm and executes the query where the DOM is legible: a closed read-verb vocabulary (snapshot | html | text | rect) + selector via querySelectorAll — never evaluated, realm answers only (never initiates), bounded payloads with honest truncation markers, write/eval-shaped requests refused loudly at the RPC gate AND structurally unrepresentable on the wire (protocol family 23 read, strict decode). The response carries the realm boot state and a FRESH HUD-pipeline receipts block (compile state, sent count+sig, applied outcome + sent→applied lag, dedupe count+last-tick — minted per read from the runtime worker) alongside the DOM answer, so a "never paints" claim has to beat the pipeline receipts in the same payload.
  • The reset-path client-sim rewind dies (ledger 1848, kin 1523). The reset-delta path was the only inbound lane that could rewind client-simulated state in normal play: sweepProjectionForReset despawned host-simulated/owned entities and the reset's create rows rebuilt them from the server's canonical blobs — but for an entity THIS client simulates, the canonical copy IS the client's own uploads, ~RTT stale by construction, so every reset rewound live hot state (TomeState clocks, owned appearance values) by RTT. Send-failure reset storms (Bun ws.send backpressure → strict false → reset; ledger 945's prod specimens) made that a per-0.6s event on saturated links. Family contract, now enforced at the seam: an inbound apply must not write client-simulated (or script-diverged) hot state it cannot prove newer.
  • The guard shape: the applyAll bypass stays (load-bearing — owned entities must re-materialize on place transitions). What changes: a new shouldAdoptEntityStateOnReset ingest hook (control-utils shouldAdoptReplicatedEntityStateOnReset) marks entities this client currently simulates; the reset sweep keeps them alive, and their reset rows are rewritten to server-stamped (clientAuthWrite !== "owned") components only — spawn-stamped fields (TomeLifetimeDeadline) and authority transitions keep landing, owned values keep the local copy: exactly the value the server itself defers to. Preserved entities the reset does NOT re-show are despawned after the row passes (server-side kills of simulated entities keep landing via resets). Unknown/foreign entities adopt wholesale, unchanged.
  • The wake handback is exempt by design: this session's OWN suspension-stamped envelope adopts the reset wholesale — while the tab was hidden the place HOST simulated it, so the resume reset's blobs are the newer truth (docs/client-auth-suspension-takeover-plan.md). A suspended FOREIGN envelope this client host-simulates under the takeover is protected like the rest of the remainder.
  • The no-confirm reset-resend loop gets a terminal state (ledger 1850 — #11360's disease, server edition: unbounded retry, no terminal). The o-53 ingest-truth lane deliberately removed the give-up-flip-to-Streaming (the 2026-07-20 one-directional freeze) and assumed a pipe that never confirms is owned by other rails — but a HALF-ALIVE socket (downlink delivering every frame, uplink eating every echo) is visible to none of them: the client's silence watchdog keeps hearing heartbeats, its self-heal re-request rides the dead uplink, and the ledger-695 eviction guards on Streaming. The GOHN specimen (07-29): full 1.26–1.7MB world snapshots every ~32s (the hold ceiling) for 25+ minutes with zero ingest_confirmed, each delivered resend re-seating the mid-flight creator back at the arena landing ("DAMNIT IT TOOK ME BACK TO AMONKET"), BAD@277 landing 0.3s after the final resend — version-independent (same shape on 5.2.6 and 5.2.9).
  • The verdict shape: a new deafness budget RESET_ACK_TERMINAL_HELD_SENDS = 11 (5 doubling holds + 6 at the 32s ceiling ≈ 3m43s of provable deafness — far past every genuine recovery the ledger-945 client budget observed). At the hold expiry that would cut snapshot N+1, the egress instead logs netcode.reset_projection.terminal (warn, with heldSends), closes the socket retryable (4437 / reset_ingest.deaf — deliverable, the downlink works), and detaches the connection into the ordinary disconnect grace. NOT a despawn and NOT a Streaming flip: the entity keeps simulating through the grace window, no snapshot rides the transition (the per-resend re-seat was the harm), and the client's existing reconnect lane lands a reattach that resumes into the same entity via replaceReadyConnection with every reset counter re-zeroed — a fresh socket, the only thing that can heal a dead uplink.
  • Ground truth always wins: a late ingest_confirmed anywhere inside the budget clears the counter through the existing echo path and the episode ends as a normal confirm — the terminal check only ever reads the counter, so a healed link can never be condemned after the fact (pinned by test).
  • The road/spline ECS component is deleted (residue cut named by #11164, probe 60f4933c, row r-dd0a41ea). The component had zero producers since roads migrated to the spline system (#6308, 2026-04): every remaining reference was a has-guarded remove or a has-guarded set — branches that could never fire. Dies with it: the component definition + its bespoke encode/decode (features/road/components.ts, the whole file), its registry row (defineRoadComponents, the road/spline wire-dictionary entry and its share of schemaHash), the 8 dead guard sites in tome/api/properties.ts + tome/interpreter.ts, and the fossil RoadSpline section of docs/API.md. Survivors, untouched: features/road/geometry.ts (getRoadGeometry/RoadSplineValue — rivers and spec-level spline: { kind: "road" } visuals are a different, live system) and the road feature barrel, which now re-exports geometry only.
  • Dotted euler writes no longer vanish against quaternion rotations (ledger 1863, GUTTERBT snowboard dig). setObjectProperty("rotation.roll", …) deep-merges onto the {x,y,z,w} read-back and the merged {x,y,z,w,roll} hit resolveRotationSpec's quat branch first — the fresh euler intent dropped with no rail (the lookAt+euler conflation had one; quat+euler had none). Both resolver twins (api/object-api.ts, spline.ts) now detect euler keys riding a full quaternion and apply them onto the quat's decomposed axes (quatToYawPitchRoll, new in the math library: intrinsic Y-X-Z, the exact inverse of quatFromEulerDegrees, gimbal-lock safe); unnamed axes keep the quaternion's values, so per-frame rotation.roll writes are absolute, not accumulating. warnRotationQuatEulerConflation joins the spec-shape rail — once per room, names what the engine did. Pure-quat and pure-euler resolutions are pinned byte-identical.
  • Savi's inspection camera sees the post chain — camera/frame captures composite the active look + bloom (the lume port of #8344's composite leg; r-df174d4e gap 1). view_live_scene camera/frame captures rendered the raw scene (ACES + sRGB only) and skipped the whole post chain, so Savi authored look scripts blind ("my inspection camera can't see the post chain… this fix is a bet until you turn around and look"). The capture now owns a post rig of the chain's own pieces (lume/capture/postfx.ts): the dual-filter bloom pyramid, the generated composite kernel (veil 0, no outline slots, no RCAS — scene + bloom → ACES → fused look → sRGB + dither), and a capture-owned LookPass for two-hop looks (offscreen ctx.target/ctx.pass graphs — the godray shape), with a 1× depth resolve feeding the depth-reading kernels (haze, dof, ctx.depth). One-shot correctness: BloomPass.prepare() / LookPass.prepare() await pipelines where the live chain keep-presents, so the first capture after a look edit carries it. Nothing active keeps the plain path byte-for-byte; any post-leg failure falls back to the plain render (never a failed capture). Every capture note names exactly what the image carries — composited / nothing-active-matches / still-compiling-recapture / broken-look-matches-viewport / could-not-apply. Burst tiles ride the same path, so filmstrips are graded too.
  • The render-time sun is readable — getAtmosphere().sunState (r-df174d4e gap 2). Savi inferred the sun's world-space direction from shadow angles in a render; the derivation (timeOfDay orbit, authored-rotation pin, cycle override, overcast dimming, night moon handover) lived only inside the client atmosphere-sync system. The sun block is extracted verbatim into one shared resolver (atmosphere-sun-color.ts resolveAtmosphereSun) feeding both the sync's light entity and the new derived read-only sunState on getAtmosphere(): { direction, rotation: { pitch, yaw }, color, intensity, body: "sun" | "moon" }, or null when the atmosphere has no sun at all — the "new worlds ship with no sun" truth as a readable fact instead of a black render to reverse-engineer. patchAtmosphere strips sunState on the way in, so a full read patched back round-trips clean.
  • The empty-derive silence hole dies (ledger 1833, War Cloud/viaduct-river): realizeScriptedPrimitiveGeometry's ok:false reason:"empty" outcome — geometry() returning without error and without a single emit — used to return geometry: null with NO report (only thrown errors were loud), after which the interpreter removed the entity's DrawMesh pointer entirely: no log, no DM, no heal traffic, no vocabulary in getLogs or view_live_scene. An object that exists, answers queries, and renders nothing anywhere, forever, silently. Now the empty outcome reports through the same behavior-error rail every other geometry() failure rides (runtime log + deduped DM, naming entity + script + "emitted no geometry — check the params/seed"), from the ONE derive funnel (deriveScriptedPrimitiveGeometry), so every lane is loud: server mint, client applySpec, live property writes, the #929 ingest drain, ground redrape.
  • view_live_scene's frame-mode miss names the real cause: a failed/empty derive on a client world now leaves a ClientBespokeGeometryDeriveFailed row (client-plane, replicate:never, forwardToRenderer:always — the Unhealable pattern) carrying { script, reason: "empty" | "error" | "no-geometry-export" }; cleared by the next successful derive, dies with the entity. The renderer's primitives store keeps it as a per-entity note (it is the ONLY renderer-side trace — a no-mesh entity has no pointer, no wait, no record), and sceneViewObjectBounds' miss carries it into the frame-miss sentence: Object "x" exists but renders nothing: its geometry script (gen/foo.js) ran and emitted no geometry on this client — the mesh is empty under the object's current params/seed. — replacing the "assets may still be loading" guess that cost a creator two sessions.
  • Stand-parent grounded egress (#11535 landed-look, finding P1). Walking from a moving platform onto foreign non-moving ground (static dock, terrain) while staying grounded now ends the ride: the attached tick detaches (no momentum — feet never left ground) when the CC's support entity is neither the platform, nor a moving attachable (the transfer branch), nor a member of the platform's own parent chain (a static-bodied part of the same assembly holds the ride until the pose watch qualifies it for transfer). Persistence-gated at 2 consecutive foreign-ground ticks so single-tick support misreads at the deck edge can't drop the ride and re-base the anchor. Before this, every detach path required airborne ticks, despawn, or a moving support — stepping onto a dock left the carry composing the rider in the departed platform's frame, dragging them at platform speed indefinitely (measured: 2.000 m of drag over 60 idle ticks at 2 m/s, exactly the deck's travel).
  • Stand-parent authority handoff (#11535 landed-look, finding P2). The engine's edge now survives its simulator: attach() writes TomeParent { parentId, stand: true }, and a simulator that finds a bare flagged edge with no local state (host migration, suspension wake, control transfer — the replicated product outlives the realm-local TomeStandRider marker and the WeakMap) ADOPTS it instead of classifying it as a script mount and orphaning it. Before this, the orphaned edge re-entered the hierarchy solve and the −10 projection on the new simulator — the two pinned-red grinds (teleport-compose depenetration + mixed-frame erosion) with no detach path ever. Scripts-win is unchanged: script writes lack the flag (never adopted), and a script overwriting a flagged edge still makes the system forget it.
  • Destroy cascade reads the flag. A rider spared from a platform's destroy cascade is now recognized by the wire-visible flag on every realm, not only where the realm-local marker lives — an NPC rider no longer dies with its platform when the destroy is issued by a realm that doesn't simulate the NPC. (SessionOwner players were already spared everywhere; the marker check remains for pre-flag edges.)
  • Named the rotation gap. Platform rotation (facing does not turn with the deck; detach momentum misses tangential ω×r) is now in the system header's out-of-v1 list rather than unstated.
  • Stand-parent — the moving-platform primitive (thread 1785355069: jacob's "parenting to the boat that should just work" + "unparenting when leaving"; tucker's brain dump is the spec). Stand on a moving entity → the realm that simulates your character parents you to it (TomeParent + Local* anchor): movement becomes deck-frame-relative (walk speed independent of platform velocity), the platform carries you through rapier's own controller pipeline, and the renderer composes your drawn pose from the platform's drawn pose via the existing parent-relative presentation rings — the deck-rider world-space follow workaround and its structural one-platform-step sawtooth die. Leave (jump, walk off, platform despawn) → unparent with world-pose continuity plus the platform's last measured velocity carried ballistically until the next grounding. Jump straight up on a constant-velocity deck and you land back on the deck (re-attach) — the deck only leaves you if it accelerates.
  • Mechanism: the rapier character controller now reports its ground-support ENTITY (getCharacterControllerGroundEntity — axis support resolved whenever grounded). A carry system (order 9000, pre-physics) composes the rider's target from the platform's current pose ⊗ the stored local anchor and adds the HORIZONTAL delta to the controller's desired translation — deliberately NOT a hierarchy body-sync: teleport-composing a capsule into exact deck contact makes rapier burn its solve on depenetration and eat the desired movement (walking dies on deck — pinned). Vertical stays the controller's own (snap-to-ground follows a falling deck; depenetration push-out follows a rising one). The anchor system (order 10000, post-physics) owns attach/detach and re-derives Local* from same-tick frames. Attach: grounded on a non-terrain, non-character physics body that is moving (kinematic/dynamic by declaration; script-moved static bodies the first tick their pose changes), cycle-guarded. Detach: airborne past a 4-tick grace, despawn, or support transfer.
  • The 1848-family seam, handled: both hierarchy solves and the client Local*←World* projection skip TomeStandRider children — at the -10 projection point the rider's World* is last-physics-step truth while the platform's is this tick's fresh ingest, and dividing those mixed-authority frames bakes the platform's inter-tick delta into Local*(the ride erodes to a standstill — pinned red).TomeStandRider is realm-local (replicate: "never"); the managed TomeParent/Local\* rows are the replicated product, and each rider's edge is managed exactly once, by its own simulator (client-predicted set / not-client-auth-external — the hierarchy solve's own predicate family). Relay tier unmounts both halves (steps no character physics).
  • Destroy cascade: riders and players (SessionOwner) parented to a destroyed platform detach instead of dying with it — a rider is not a part.
  • Scope (v1, named): any character-controller entity rides — players and kinematic NPCs alike. Out: characters as platforms (head-standing never parents), items without character controllers (dynamic props ride via physics friction, not parenting), mantle-runtime places (mantle's CC already velocity-follows platforms), 2d-top (no vertical support semantics).
  • api.uniqueId() can no longer collapse into one permanently-identical id (incident 0cb62ea7, THE VOW on 5.2.6). The poison chain: any exec's setup spawns the bare tome/exec entity with no TomeIdSeq; a later spawn-only exec shipped a PARTIAL counter delta ({ spawn: k }, the untouched unique lane dropped as a zero delta), merge built a partial live component, and the next uniqueId mint did undefined + 1 = NaN — from then on every call returned the same _NaN-suffixed string forever (spawns silently adopted each other, destroys missed), and the NaN delta re-shipped on every merge. Killed at all three layers: the mint is total over malformed stored values (a non-finite lane re-derives from the persisted-spec scan — long-lived rooms already carrying the poison self-heal at their next mint, no reset needed), the exec overlay never ships non-finite counter deltas and ships snapshot-missing keys at delta 0 (a partial TomeIdSeq is unrepresentable at merge), and merge-side counter arithmetic skips non-finite input on both arms. Applies to both lanes of the machinery — api.spawn's minted ids ride the same component.

Engine v5.2.9

Released July 29, 2026

  • Saving a script in a world that rebuilds its objects (city generators, wave spawners, destroy-and-respawn builders) no longer makes everything blink out and back over and over — rebuilt objects land once and stay.
  • When the room does refuse to create something (flood limits, permission gates), your game's log now says so by name instead of the object just quietly never appearing.
  • Models you place while they're still being prepared now show up on their own once they're ready — no more invisible props that stay gone until everyone rejoins the room.
  • When Savi checks the scene, a model that actually failed now reads as failed instead of "still generating" forever.
  • Firefox players get your world instead of a black screen — a shader Firefox refused to compile is fixed, and every shader the engine generates is now checked against Firefox's rules before it can ship.
  • Safari and iPhone players stop losing their session to "graphics device stopped responding" — a Safari bug the engine now works around automatically.
  • Big outdoor worlds run roughly twice as fast on phones (one test iPhone went from ~15 to ~27fps), and phones get their own lighter bloom so glow effects stop eating the frame budget.
  • Scattered objects (forests, rocks, crowds) now respect every setting you put on their template — most visibly castShadow: false: turning shadows off on a scatter of 400 trees actually turns them off now, instead of silently ignoring you and paying the full shadow cost.
  • Savi got sharper at two things this build: kills in multiplayer combat feel instant for the shooter (no wait-for-server hitch in the death reaction), and floating-island worlds get true open sky below the horizon instead of a fake ground haze — she now reaches for both patterns on her own.
  • When a texture script draws something but the texture comes out blank, Savi now gets told exactly that — instead of the engine calling it a success and leaving you staring at an untextured object wondering why.
  • Fixed a runaway save loop: some games were silently saving new versions every second (one hit 2.6 saves per second, thousands of junk versions) — burning storage and making version history useless. Saves only happen when something actually changed now.
  • Savi can now look at your game the way a phone player sees it — checking that menus and HUDs fit a small screen — instead of only ever seeing the desktop view.
  • Side-facing walls (facing: 'x') with off-center doors or windows now collide where the opening is drawn — no more invisible wall in a visible doorway with a secret walk-through spot mirrored across the wall's center.
  • Savi's 3D-model workshop got a box of professional modeling shortcuts — custom models come back with nicer edges and surface detail in one pass.
  • Your game has a real database now — one save file per game, with your build room's data kept physically separate from live players' data. Savi writes ordinary SQL for save systems, load-at-join, save slots, shared world state, and analytics queries — and games that saved before the database keep every old save, carried over automatically.
  • Misspell or invent an option on an effect sprite/ribbon/light and Savi now gets told exactly which word the engine doesn't know, with the list of words it does — instead of the effect quietly ignoring it and leaving everyone staring at a fireball that "should" have soft edges.
  • Games with always-on regen, timers, or meters no longer flood their version history with a save every half-second — pure player-state ticks batch up quietly (real changes still save instantly, and nothing is lost if the room crashes).
  • Ask for crouch in a first-person game and Savi now lowers the camera the way you'd expect, instead of tilting it toward the floor.
  • Swapping or deleting sprite art mid-session no longer leaves the engine silently re-requesting the dead art every 10 seconds until someone reloads the room.
  • Dragging a file from your computer onto the game view works now — the drop lands in the import overlay instead of vanishing into the game.
  • Right after sending a voice message, tapping Tab opens the chat box again instead of doing nothing.
  • Cold animation sheets no longer flash giant — the engine holds the whole last frame until the new sheet lands.
  • Safari and iPhone players get another big speed-up: the engine stops using a rendering path Safari secretly rebuilds from scratch every frame, and three per-tick busywork loops that hit Safari's JavaScript engine hardest are gone entirely.

technical notes

  • Entity fences stop eating CREATE rows — the city-blink/anti-phase-rebuild class dies (ledger 1795, dig 8b34645c). A forwarded-DELETE entity fence no longer drops CREATE rows (updates/deletes/events stay fenced): a create row is a deliberate re-mint (ENTITY_NEW at the sender's log walker), never a pre-adoption straggler, and the admission path already owns same-id collisions. This deletes the wild loop's only receipted create-killer — prod 5.2.8 "Clog a Toilet" (incidents 119a08e8 → 8b34645c) churned a 123-tick anti-phase rebuild for the room's whole life: fence eats creates → never acked → 3s ghost retire → confirm walk fails → rebuild → next fence. Destroy/respawn builders now converge by construction.
  • create.nack on every create-drop class: every create row the StateDeltas drain DROPS now answers {kind:"create.nack", ids, reason} beside the create.ack push — create-gate rejects, chain-validation unwinds (displaced-host included), flood-budget prefix drops, and the suspended-session wholesale drop. Client-side a nack fast-forwards the pending entry's grace (ghost retires on the next drain, not 3s later) and the client-ring receipt names the room's reason. No create can die silently — silence now means only "the message never arrived", which is what the grace window remains armed for.
  • Dead-parked model loads re-probe on a long cooldown — the silent forever-park is eliminated (ledger 1808, the Thunder Bluff latch, dig 22f87a4d). Models placed while their import was still cooking never transitioned to rendered after the asset completed: dead answers collected during the cook window burned the terminal budget into retryAfter=INFINITY, and both documented re-arm doors are dead mid-session (manifest re-add dedupes at the sim AssetService; room reattach needs a reconnect). Dead-4xx/auth give-ups now park on LONG_RETRY_COOLDOWN_MS (one bounded re-probe per 5 minutes, autonomous via the retry wake) — any asset that becomes servable converges within one cooldown with zero external signal. INFINITY now belongs only to states with their own explicit door (pre-session cap, content-policy rejection). Room attach still re-arms immediately (ledger 1183 semantics preserved).
  • Eye honesty: getUnreadySceneAssets reads a dead 4xx answer as 'failed', never 'generating' — a cooking asset answers 202, so a dead answer is a verdict, not progress (401/403 stay 'generating': auth verdicts the session cookie, not the asset). Savi's live-eye stops promising assets the engine had stopped requesting.
  • The upload-drop receipt rail's dedupe key drops its per-entity component (dash-named ids made one 80-entity sweep 80 warn buckets in one tick, evicting the asset-load receipts this incident needed): one line per (client, gate) per 30s window, every drop still counted in stats.
  • Firefox-valid generated WGSL — cullSelectWgsl gains an explicit unreachable tail return in aggClaim so all five variants validate under naga (r-859dee09). Firefox's WebGPU validates shaders with naga where Chrome/Safari use Tint/Dawn; a Tint-accepts/naga-rejects shader parks the pipeline at createShaderModule, the canvas goes black, and telemetry reports only second-order resource-state noise (forensics: 152/153 victims of the affected family were Firefox, one a fresh starter world). The pulled vertex source also threads through displacementWgsl correctly.
  • CI gains the missing validation axis: a naga gate over lume's GENERATED WGSL (#11314). Every WGSL gate previously validated against Dawn only, so the whole Tint-accepts/naga-rejects family was structurally invisible. The gate enumerates the generators' output — every lume shader generator × its realistic parameter matrix, couplings taken from the engine's own derivation sites, 1,538 artifacts across culling, terrain, and post — and validates the exact strings prod builds pipelines from. The class can't come back unseen.
  • Shipped Safari/WebKit renders again — fragment stub for vertex-only shadow bundles (WebKit bug 319980). Root cause of the Safari/iOS GPU session deaths ("graphics device stopped responding" on desktop, silent WebContent kills on iPhone): every lane shadow bundle pairs a vertex-only caster pipeline with a depth-only bundle encoder, and shipped WebKit's RenderPipeline::validateRenderBundle rejects that spec-legal shape at setPipeline — the bundle records invalid every frame and the sustained validation storm survives both render-chain rebuilds. Fixed on WebKit main 2026-07-22 but months from shipping, so: probeFragmentlessBundleSupport() runs one throwaway vertex-only bundle at device init inside an error scope (a probe that cannot run never convicts), and when armed, laneShadowWgsl gains an empty @fragment fn fs() {} bound with zero color targets — a construction-frozen session latch, same law as the sample-count/color-format contracts.
  • Phone texture-page mip admission — 15→27fps on iPhone (7.9× draw cut). The mobile texture profile (256² pages) refused every 512² world texture to the oversized direct pools, whose lanes can never activate pulled-attribute batching — phone-tier sessions drew the forest one indirect draw per unique geometry (1,333 draws vs 169 on desktop, ~18ms of empty-indirect Metal encoder overhead in scene.opaque). A 512² chain's mip 1 IS the 256² image, so admission now seats an oversize chain at its first fitting level and the residency copy reads the tail. iPhone receipts: pinned rung 0 1,320→156 draws, scene.opaque 19.6→5.6ms, GPU 38→25.8ms; unpinned natural ladder ~17→~27fps, and the frame-budget guard no longer parks the look (thermal ramp flattened). Tail admission is per-tier policy — desktop keeps native-res pools.
  • Offset admission refuses off-block-grid base dims — the zoo 512×285 device-loss storm. Compressed formats require block-aligned dims at createTexture's BASE level; halving a block-aligned NPOT chain can land off-grid (2048×1140 ASTC at offset 2 = 512×285), minting an invalid texture that stormed every lane bundle binding it and killed the session with GPU device loss. Off-grid offsets are refused at admission.
  • Bloom shape presets — full/lean/phone on the quality ladder. Bloom's 7-pass half-res 4-level pyramid becomes 'full'; 'lean' (half-res, 3 levels, 5-tap Kawase) and 'phone' (quarter-res, 3 levels) join it as a construction-frozen session contract. The post stack runs at OUTPUT resolution — TAAU internal-res scaling never protected the phone from bloom — so the phone preset is the first bloom cost cut that lands on device. A/B override: globalThis.spawnBloomShapeOverride + render-harness --bloom-shape.
  • Depth-local look fusion — haze rides the composite, look.present dies on hazed frames. classifyLookFusion three-classes the active look (pixel-local / depth-local / offscreen); vocabulary haze (a fixed per-pixel depth tap) now fuses into the composite (zero new bindings on the TAAU-fused kernel), freeing the full-res rgba16float look intermediate on leaving two-hop. Standalone pass stays byte-identical as the offscreen path + A/B control arm.
  • Transient textures survive usage injection (F1) — zero validation errors at any creation time. WebGPU Inspector ORs COPY_SRC|TEXTURE_BINDING inside wrapped createTexture and Dawn rejects the merged mask (every scene.alpha submit failed under instrumentation). The pool probes once at first transient request (a 1×1 attachment-only create reveals the wrapper), degrades to non-memoryless up front, and keeps per-texture destroy-and-recreate as backstop for late-attaching wrappers.
  • Scatter templates stop silently dropping castShadow/receiveShadow (+14 more keys) — the hoist list is derived, not hand-maintained (incident 7fdfd766). SCATTER_TEMPLATE_PROPERTY_KEYS omitted 16 keys that were valid per the type (ScatterTemplate includes Partial<ObjectProperties>) and silently vanished in normalizeTemplateProperties when written top-level: attachment, pivot, visibleInFirstPerson, visibleTo, castShadow, receiveShadow, spline, mixer, ik, jiggle, collider2d, sign, fx, interpolation, npc, blueprint, brush. Incident app ca4e9c87: 403 scattered trees all read cast:true against templates declaring castShadow:false — full sun-cascade cost for shadows the creator turned off. The hoist list is now DERIVED from SCATTER_TEMPLATE_KEY_ROLES, a role map whose satisfies makes classification exhaustive — a new ObjectSpec/ObjectProperties key is a tsc error until marked hoist/spec/dropped (with a reason comment for every drop). Spelling-parity tests cover every hoisted key.
  • Skills teaching wave (rides this build's skills.json): combat.md teaches predicted-kill presentation — death reactions project locally, authority reconciles (tide's f3a09a5f §3c, #11288); world-composition.md teaches the open-sky idiom — floating islands set atmosphere.ground: false (two-creator demand in one week: Lightwake's 14 burned versions + izkimar; the cure itself ships since 5.2.8, the teaching now points at it); weaves.md teaches the structured inter-wisp handoff shape that parses at the seam (#11285); match-a-reference.md's three prompt clauses learn their true targets (#11338); drawn-art.md's cliff step gets a bounded budget — one doubling, then it's a camera-height problem with named levers (#11341).
  • Texture bakes ship a pixel-coverage receipt — a blank bake is a named outcome, never silent success (incident c2c60fea, ledger 1805). The bake worker reads an exact non-zero pixel census + strided sample hash off the finished canvas before transferToImageBitmap and ships it with every ok bake; the host raises texture-script-blank-bake when the census reads blank after a draw that ran ops. The blank bake still ships — the receipt is a name, never a gate.
  • texture(ctx) returning a thenable is a runtime fault (texture(ctx) must be synchronous): the canvas is captured the moment texture() returns, so an async draw silently shipped only the ops before its first await.
  • New browser-gate suite bakes the incident probe through the REAL built worker artifact on chromium+webkit and asserts bitmap content pixel-exactly — the raster class vitest's recording stub structurally cannot see.
  • STOP PAIR: the update storm (runaway version-minting) is cured at both ends (dig 535afdad, ledger 1797). Engine side reverts #11111's bidirectional singleplayer template fold: the fold judged live writes against a template mirror its own enqueue never advanced, so a per-tick state write on a promoted key re-enqueued every tick forever, and the coalescing persist sink minted one game_specs version per batch — Slime Arsenal (90469935) minted at 2.6 versions/second. Live state writes on template-carried keys are once again live-only (the one-directional promotion at persist:true write time stays). Known trade, taken deliberately: #11111's resurrection cure re-opens — a template-carried key cleared by a plain live write re-seeds its stale template value on rejoin (the goblinjo class, dig 7ed7f11e) — until a storm-proof fold design lands.
  • Platform backstop (kiln-side, active for every room at kiln deploy regardless of engine pin): a mutations batch that changes nothing can never mint a game_specs version — the SDK mutations route serialize-compares the folded doc against the pre-apply head and answers the existing no-op shape (200, updated:false, head version) on equality, inserting nothing (prod receipt: Sky Islands 9bfc70a0 minted byte-identical v4740/v4741). The platform-wide floor for the empty-mint class, whatever the future writer.
  • Zoo: VAT Lab pen — vertex animation textures from existing primitives, zero engine changes (jure's spike, #11306). Three exhibits + the bake-on-display board (KeyX): soft (80-figure one-draw crowd wave), rigid (60-brick shatter with baked quaternion tumble + staggered time-reverse rebuild), and sim-sourced cloth (Verlet flag, crossfaded seamless loop) — texture scripts bake the data (RGBA8, alpha-255 law), bespoke geometry carries VAT addressing in uv, scripted materials replay it in the vertex stage (~287 KB total). Engine findings for the rendering lane, named: vertex-stage texture sampling works today; scripted materials draw standalone (merged-mesh crowds cap at the 16,384-vert bespoke budget — instancing is the scale-up ask); positionNode does not reach shadows (exhibits set castShadow:false); culling uses rest bounds (envelope markers).
  • view_player_screen accepts viewport: { width, height } — the schema catches up to the booth (r-c853e0d1 item 1a). The tool's inputSchema was z.object({}) while the booth underneath already honored viewport end-to-end (resolveViewport clamps 64–4096, warm eyes resize per job) — Savi could not ask for the phone-sized look her own craft clause ("the HUD fits a thumb-wide screen") requires. Viewport rides the booth request verbatim; the caption names the served size from the booth's own receipt; bounds are mirrored at the tool boundary; a provider-level schema pin asserts only viewport {width, height} exists (a param the booth ignores would be a new lie). game-ui.md retires "asking the player — they see them; captures don't" (false since the composited eye landed): view_player_screen IS the capture that shows anchored plates.
  • facing:'x' wall colliders no longer mirror their holes across the wall's center (SHUDDER incident 4bbd28d3; born with the wall feature in #5529, survived all three renderer eras). The mesh (createWallGeometry) builds the wall shape in XY and rotates it +π/2 about Y, sending an authored hole.x=+8 to local z=−8; the collider grid (generateWallCollider) wrote shape-x straight into +Z, landing the same hole at z=+8. Every facing:'x' wall with an off-center hole got both player-facing faults at once: an invisible wall at the drawn doorway and a walk-through phantom at the mirrored spot (2·hole.x apart). The collider now mirrors hole.x into the mesh's frame — creators authored five months of worlds against what renders, so the invisible side conforms. Centered holes (x=0) were mirror-invariant and are byte-identical before/after; facing:'z' and 'y' were already consistent and are pinned by new tests, including a mesh↔collider projected-occupancy parity sweep across all three facings.
  • Blender workshop: quickmenu's 10 real algorithms land with their teaching — qm_apply one-call procedural detail (jure's harvest, #11335). The workshop image gains qm.py (355 lines of real quickmenu ports) provisioned as a Blender addon, with the blender-game-assets skill teaching the one-call qm_apply shape so wisps reach for proven procedural detail (bevels, greebles, surface breakup) instead of hand-rolling operators. Workshop-image change rides its own image build; the engine build carries the skill teaching.
  • One save file per game+mode — api.sql is Savi's whole storage surface (the storage-rail deslop, #11259). Every game owns one SQLite database per mode, dev and live physically separate, and api.sql is the one verb: a tagged template whose interpolations bind as parameters, one call one atomic transaction (semicolons = ordered statements that commit or roll back together), errors throw, budgets fail loud (≤64 statements / ~250ms wall / ~10k rows / 4MB per call; size warn near 8GB, growth refused at 10GB with reads and cleanup DELETEs always working). Behavior-script awaits settle at tick boundaries so a load enters the sim atomically; run_script hits the dev DB the moment it executes; scripts/db.js migrate(sql) runs under a door-held lease exactly once per (mode DB, spec version) — a throwing migrate never blocks play, it reports and re-runs at the next boot. The old rail is deleted, not wrapped: api.updateStorage, the lifecycle storage lane, the eight storage:* builtin jobs, createSDKStorage, and the gsdk documents/CAS family are gone, with tombstones on every old name teaching the live verb. Terrain edits and room-state resume are re-planted on _spawn_chunks/_room_state rows (the 10MB doc cliff deleted); pre-database saves surface read-only in legacy_kv via an idempotent, crash-resumable boot importer. New data-and-saves skill replaces lifecycle-and-persistence + jobs-and-storage.
  • fx sink options the engine does not know now talk back instead of silently no-oping (the soft: lesson — soft: 0.4 depth fade was authored in the wild for months against enfeul's fireballs while sprite() silently dropped the key; B5 landed the fade, this closes the class). sprite() / ribbon() / light() refuse unknown option keys with the builder's full vocabulary in the message (fx: unknown sprite() option "softness" — options are: texture, textures, blend, layerMask, align, stretch, axis, soft), and the compile issues rail (validateFxProgram) holds hand-built sink objects (render: { kind: "sprite", … }) to the same law: unknown sink keys, unknown sink kinds, and out-of-law hand-built soft values (non-finite / negative) each report a path-addressed issue instead of a silent drop or a mid-validation crash. Builder throws and FxProgramError both surface through the existing fx.compile fault channel (kept-last-program + one DM per broken version).
  • Singleplayer per-tick player-state persistence coalesces — the third door on the version-storm class (ledger 1814, dig ef0675cc). A behavior script writing player state every 30Hz tick (enfeul's NANO-WELD regen perk, +1.2 hull/sec) minted one FULL game_specs row per drain at the kiln RTT cap — ~115 rows/min × 775KB for as long as regen ran. The persist funnel now stamps kernel-authored singleplayer batches that are exclusively patchPlayer{state} writes as playerStateOnly; the write-ahead ledger merges stamped appends into the queue tail (latest-wins per key where merge-patch composition is provably exact against the fold, sequential otherwise) and holds a sole stamped head until 30s since the last player-state flush. Any structural batch behind the head lifts the hold immediately — order never bends — and /update freshness, idle save, and container shutdown force the flush. Crash-safe from append (the held fold is DO-durable and replays whole); only kiln VISIBILITY of pure player-state deltas lags ≤30s. Pinned: a 60s regen storm mints ≤2 rows (was ~115); unstamped batches drain byte-identically; god-mode hands, multiplayer worlds, and mixed batches are never stamped.
  • Skills teaching additions (ride this build's skills.json): camera-first-person.md teaches crouch as an eye-height change on the FPS feature list — route eyeHeight, never camera pitch (creator report ma1neac: Savi's first-try crouch tilted the camera; eyeHeight existed all along, invisible from her base context); combat.md's Predicted Kills section gains its premise line naming the current host-event NPC damage shape (an engine change to client-auth NPC intents rewrites the section in the same PR); debugging.md gains the measured-signature-is-a-grep-key line (dig f4510109: five mechanism fixes beaten every tick by a stale clamp the user's own number named twice).
  • Retired sprite art stops being asked for forever — the sprite-metadata derive queue prunes at consumption (ledger 1813, dump 9766d757). The host's derive queue had success-only exits: entity despawn and spec art-swap never pruned it, so a mid-session art retire kept paying derive fetches for retired art through the claimed-fetch seam and re-offered rail.assetMetadata every 10s into the relay's unreferenced refusal — forever (4 retired refs × ≥49min; only a reload cleared it). The sprite2d reference walk moves into ONE shared predicate (tome/sprite-texture-refs.ts) consulted at fetch launch and offer/landing — the queue is scheduling state, never a shadow reference list, so despawn and art-swap retire their asks the same sweep — and the relay's unreferenced verdict now routes into hydration as a terminal door. Deliberately not #11334's give-up pole: still-referenced refs keep their ordinary retry ladder (pinned), and a retired ref re-queues on a fresh ladder from any new live reference.
  • Studio input pair (kiln-side — rides kiln deploys, live for every room regardless of engine pin). (1) File drags over the game canvas reach the page again (#11352): the kernel iframe is cross-origin, so a hovering drag routed every drag event into its document and the parent's AssetDropOverlayV4 went silent — drops over the canvas vanished into the game (thread 1785273583, dump e5cab07b). Kernel frames now go pointer-inert only while a Files drag is in flight (DataTransfer.types gate keeps in-game pointer interactions untouched; a depth counter defeats dragenter/dragleave flicker), and every end-of-drag signal restores — drop, dragend, leave-to-outside, window blur, safety timeout, unmount. (2) A quick Tab tap right after a voice send toggles chat again (#11354): a released take now answers hasVoicedAudio() false, so the 0.5–6s settle window stops misreading the tap as TALK and swallowing the chat toggle (cal, dump ea53b5c2).
  • Cold animation sheets stop flashing giant — the texture-swap hold keeps the WHOLE last-good draw tuple (ledger 1817, #11370). A sheet-per-animation script authors texture+uvRect+size+anchor as one write, but applyComposedSprite's mid-fetch hold kept only the old texture while the new sheet's geometry applied immediately — first play of a cold sheet rendered the old sheet's texels stretched over the new-sized quad (~6× blowup) until the fetch landed. The hold now carries the entire last-good draw value and the existing subscribe re-applies the latest authored value whole when the texture arrives; geometry churn without a texture change never enters the hold (the warm spritesheet-frame path stays immediate, pinned by test).
  • WebKit sessions bypass render bundles — the Safari/iOS wall collapse closes (#11337; WebKit RenderBundleEncoder.mm, deep-dive receipts in docs/renderer-gpu-deep-dive.md). Shipped WebKit marks any bundle carrying drawIndirect/drawIndexedIndirect as requiresCommandReplay and re-replays its commands into freshly constructed Metal indirect command buffers on EVERY executeBundles call — the AGX ICB construction pegged the GPU process's WebGPU work-queue thread (93.6% of its samples in replayCommands/makeICB) and the frame's present handshake queued behind it at p50 275ms. This is #8970's iPhone wall collapse, named. Every lume bundle draws indirect (cull args buffers), so on those builds bundles are pure overhead: the UA verdict reusableIndirectBundles:false (bare-AppleWebKit desktop UAs + every iOS browser; not probeable via error scopes — a perf pathology, not a validation failure) drops the bundle profile and every contributor takes its existing direct-command path. No parallel mechanism; Chrome/Dawn keeps bundles. Riding the same hunt, three JSC per-tick churn eliminations (sim-side −63%): bind-group/layout cache keys become an interned-integer trie and canonicalJson dies (per-frame rope flatten + hash was a dominant JSC cost); ground sampling goes revision-driven with a per-foot IK memo (the per-frame terrain index rebuild could never survive a frame by construction); scripted-primitive ingest settles SILENT on a hook-fed dirty set (~4,800 resolveDeriveWork calls × 30Hz on a settled world → zero, pinned by silence tests).

Engine v5.2.8

Released July 28, 2026

  • Floating-island and sky-world builds: set atmosphere: { ground: false } and the world stops pretending there's a ground below the horizon. Look down past an island's edge and you see real sky haze deepening toward the depths — not the flat sea-colored band. One line, per place; everything else about the sky (day/night, clouds, stars, fog) keeps working exactly as before.
  • Joining a world no longer risks a false "physics is slowing down" alert: the engine now waits out the normal loading blip (the first seconds after joining or entering a new area) and only reports physics slowdown to Savi when it actually persists for several seconds. Real slowdowns still get reported with the same numbers and fixes as before.
  • Power lines got their poles back — a bare power line plants grounded poles with crossarms along the run again instead of leaving the wires floating in the sky. Want a cable strung between buildings with no poles? Say so: poles: false hangs it as one draped line.
  • Games with HTML UI no longer risk a dead menu (or a soft-locked player) on slower devices. The UI's boot loader is now ~13x smaller, a busy device gets time to catch up instead of being cut off at 4 seconds, and if the UI ever does fail to appear it now quietly retries as you play instead of staying gone until a page reload.
  • An accidental infinite loop in a script (like while (true) with no way out) no longer freezes the room forever. The engine stops the runaway loop after a very large budget (real game loops never come close), keeps the game running, and hands Savi a clear error naming the script and the loop so she can fix it — a teachable moment instead of a dead room.
  • Terrain edits can no longer eat your ponds and paths by accident. Savi now adds, tweaks, and removes rivers, ponds, paths, and flatten pads one at a time, by name — so carving a new path can never silently wipe the pond you built yesterday. If anything tries the old wipe-everything write, the engine refuses and says exactly which marks it was about to delete.
  • Multiplayer effects are honest about their reach now: targeting other players with audience: { kind: "nearby" | "place" | "all" } (or another player's id) on screenShake, vignette, sounds, and other juice never actually reached them — it silently played for the calling player only. The call still works exactly as before, but it now logs a clear note explaining that effects are self-only in multiplayer for now and how to do it right: fire the effect from each player's own trigger (onInput/onInteract/onTriggerEnter), or drop the audience option on world-anchored effects (bursts, decals, positioned sounds) — those already reach every nearby player.
  • Fixed a leak where objects spawned with a lifetime (fireballs, pickups, timed props) could linger forever in a room whose only player closed or refreshed the tab and never came back — the room now cleans them up right on schedule even with nobody there.
  • setObjectProperty("properties.light.intensity", …) now just works — keys written with the spec's properties. prefix resolve exactly like the bare key instead of being ignored with an "unknown property" warning.
  • Fog values the renderer can't honor as written now tell you at patch time: linear fog with far <= near (which renders a hard fog wall instead of a gradient) and negative exp2 density (which renders like its absolute value) warn with the key, your value, and the allowed range — instead of silently rendering something you didn't ask for.
  • Fixed a bug where scripts on a player rig couldn't reach the parts they'd spawned onto it — writes like api.setObjectProperty(api.id + "-gun", …) were silently thrown away with "entity not found" warnings even though the part was right there. Those writes land now: player-part color changes, attachments, and animations that never took effect come alive, and the warning flood that came with them stops.
  • Looking up an object by a short id that doesn't exist is now effectively free instead of costing a scan of every object in the world — games that check stored ids every tick no longer pay a hidden per-miss tax that grew with world size.
  • Fixed a multiplayer bug that hit solo sessions hardest: switching tabs (for example to chat with Savi), refreshing the page, or a brief connection stall could permanently desync your room — you kept playing normally on your screen while the room stopped counting your progress (pickups, health, wave triggers silently dead), and only a full room restart fixed it. The room now keeps your session's authority seated across those interruptions, so everything you do keeps landing when you come back.
  • Debug dumps now show whether your game's UI is actually repainting: if a menu looks stuck on screen while the game itself is fine, the dump names exactly which step stopped — including HUDs the engine refused to mount because of a broken button handler, which previously froze silently.
  • Fixed a multiplayer bug where switching away from the tab and coming back could leave you in a frozen copy of the world — everything you built or spawned after returning silently vanished for everyone else, and neither reloading nor restarting the room recovered it. The engine now detects that stuck state within seconds and re-syncs you automatically; your session picks up live from the real world state, no tab close needed.
  • Explosions, magic, and weather can play real animation frames now — upload one sprite sheet and it just works
  • Fog and smoke hug the ground and walls instead of slicing through them, and heat shimmer over fire actually bends the world behind it
  • Shell casings, hail, and debris bounce — each piece with its own weight
  • Effects can orbit, spiral, and tether between moving things with exact math, and they keep up on their own
  • Lights ride your effects now (fireflies light the ground they drift over)
  • Marks can grow and fade on their own — a blood pool that spreads, a puddle that dries
  • Objects can burn away with a glowing dissolve instead of blinking out
  • A whole weapon's feel — muzzle flash, tracer, casings, impact — fits in one effect file, fired once per shot

technical notes

  • Below-horizon open sky — atmosphere.ground: false (adjudication r-e98895c5; floating-island worlds). One declarative place-level atmosphere property: ground: false declares "no ground plane" and the procedural sky becomes full-sphere atmosphere. In the sky-view LUT (sky-luts.ts), below-horizon rays march to the atmosphere's far exit instead of the ground hit, the medium continues at sea-level density below the reference radius (per-sample radial clamp), and the lit-ground bounce term is skipped — the below-horizon rows come out as physically-derived downward aerial perspective (Hillaire's own integral with the ground intersection removed: haze that saturates toward the sea-level in-scatter limit at the nadir), NOT a mirror and NOT a pinned horizon clamp. The horizon stays seamless by construction (the integrand varies continuously through the tangent ray). Plumbing: schema → resolveAtmosphereSkySource → DrawSkybox.ground → skybox handler → SkyValues.openSky → the Sky uniform's spare pad slot; the flag folds into the scattering change key, so a live toggle rebuilds the LUTs and the IBL (the environment capture and sky-driven ambient inherit the haze below automatically — floating islands get lit from below by it, physically right). The grounded default is untouched: openSky = 0 leaves every sky-view expression the grounded path verbatim (above-horizon rows are unchanged even when open — those rays never hit ground), transmittance/multi-scatter LUTs never read the flag (the planet remains the twilight shadower and the ambient's ground), and the atmosphere-open-sky render-harness fixture pins the open-sky signature on GPU (its thresholds discriminate both states with measured margins).
  • physics/step perf reports become lagging indicators (jacob's ruling, #inventors 1785193978, 07-27: "performance reports to savi should be lagging until we're 100% sure that there's an issue" — his screenshot: a single-tick physics blip at JOIN, zero moving objects, reached Savi as a client perf report and the creator as a slowdown notice; the three-consecutive-strikes trip from #10952 fired ~100ms into any breach with no join exemption). The physics-step-budget lane (behavior-watchdog.ts) now requires the breach to HOLD unbroken for PHYSICS_STEP_SUSTAIN_SECONDS (5s) of wall clock on top of the existing consecutive/separated strike laws — a spike of any size, even several over-budget ticks in a row, is never evidence — and the first PHYSICS_STEP_JOIN_GRACE_SECONDS (10s) after the world boots or after any place first steps its physics (PlacePhysicsSteppedTickResource, the same stamp the transition veil's settled reads) are exempt entirely: no evidence accrues, so a join/place-load blip reports nothing, ever. A visibility resume re-anchors the grace (the flip already wiped the lane's state; the wake burst is the same expected-physics class as a join). A genuine population melt (probe 5703dcd7's every-tick billing) still clears both gates trivially and reports once — the mechanism, teach numbers, consent rule, DM latch, and DD breadcrumb are all unchanged.
  • BREAKING: powerline splines mint poles by default again (jacob's 07-27 walk of the 5.2.7 zoo: the bare 'Powerline: wood' exhibit rendered as wires floating unanchored — one run ending mid-air, the wire bundle sagging against nothing). #10870's poles-opt-in default was jacob's ruling for the string kinds (fairy lights, bunting); powerline got swept into the same default, but a power line's poles are the product, not optional supports. Bare powerline runs route to the grounded pole+crossarm layout (pole_N + per-span wire_<span>_<wire>, byte-identical to pre-#10870); poles: false routes a run to the #10870 draped layout (one whole-run wire_N cable per wireCount, hung at the pole layout's wire height). poles: true stays valid and redundant. Stringlight/bannerline defaults untouched.
  • Authored-realm boot: the starvation class is eliminated, not tuned (2026-07 incident: 3 prod apps in 8h, one hard soft-lock — a fixed 4s hello deadline racing a 2.85MB iframe module on starved devices, with a session-permanent failure latch). Four structural changes, one per mechanism:
    • The engine is out of the UI iframe. frame/lifecycle.ts imported @/tome/compiler — the whole-engine compile rail — which dragged ObjectAPI, mantle physics, terrain streaming and frustum culling into the authored-realm bundle (1005 modules, 2,854,157 minified bytes). The module rail (require()/lib compilation, source transforms, dep watches, UI-lifecycle compile) now lives in src/tome/script-modules.ts (engine-free; structuredCloneTomeScriptValue's snapshot-proxy cluster extracted to script-value-clone.ts), and compiler.ts re-exports it — one implementation, zero engine-side import churn. The bundle is now 213,587 minified bytes (62 modules) — a 92.5% cut — and frame-entry.size.test.ts pins a 512,000-byte CI ceiling so an engine-scale import can never creep back in. require("builtin/*"), quantize-off compilation, uiGlobals/audio injections, and the deterministic-math/numeric-runtime installs are byte-identical in-frame.
    • The boot deadline arms on progress, not wall clock. The iframe load event splits the two failure classes the old single timer conflated: NO LOAD (asset fetch failed/blocked — the class a cache-busted retry can fix) keeps the retry ladder on REALM_BOOT_LOAD_TIMEOUT_MS; LOADED-BUT-NO-HELLO (starvation, or a pre-hello crash carried by the boot-error relay) never retries against the identical immutable module — it faults once (diagnostic) and keeps waiting through REALM_BOOT_HELLO_EXTENDED_TIMEOUT_MS (30s), so a starved frame that catches up still boots. Hello is accepted from either pre-hello phase (module scripts execute before the document load event).
    • The terminal latch is deleted. A failed target re-arms on the next mount-trigger message (nonempty render / lifecycle mount — the lazy-mount trigger, both targets), giving the field-requested periodic re-retry with zero new machinery; the dead, caller-less remount() escape hatch is removed. Boot faults latch one-per-phase-per-target-per-session, so re-armed ladders retry silently instead of spamming ui.realm.
    • The fault lines name the class. shell document never loaded … (network/serving) vs authored realm boot starved (… no hello …still waiting…) (starvation/parse, with crash detail when relayed) vs no ready after retry (init handshake stall) — the next dig is a read, not an 8-hour correlation. All keep the authored realm boot grep prefix; the misleading always-epoch 2 line is gone.
  • Script loop budget: the wedge-the-renderer class is contained (ledger 1328). A creator behavior script that never returns (while (true) {}) used to block the tick forever on every surface scripts run — browser client-simulated rooms, the Bun container tier, workerd realms — and all wall-clock watchdogs live ON the tick, so none could fire (behavior-watchdog.ts named the limitation). Now every body compiled through the single compileFunctionFactory choke point (tome compiler, terrain generator-runtime, voxel templates share the memo) gets AST-level back-edge instrumentation (engine/library/script-loop-budget.ts, acorn parse — real AST, never regex): each function that directly contains a loop declares a per-invocation int32 counter, each loop back-edge (while / do-while / for / for-of / for-in, labeled included) pays one increment + one predictable branch, and the full budget comparison runs every 1024th iteration. Exhaustion throws the named ScriptBudgetExceeded (script attribution + approximate loop line), which rides the ordinary throwing-handler containment into the runtime logs — the tick CONTINUES and Savi gets a teachable fault instead of a dead room.
  • Ceiling is generous by design (2^28 ≈ 268M iterations per invocation — a wedge-catcher, never a perf governor): the zoo's heaviest real exhibits stay under ~10^4 iterations/invocation, the heaviest legitimate engine-invited shape (full-map generator sweep ≈ 8.4M) sits ~32× under the ceiling, and a tight wedge still trips in well under a second. Per-invocation counters mean bounded per-tick loops can never accumulate toward the budget across ticks.
  • Fail-open law: unparseable bodies compile byte-identical to before (containment never breaks a script that works today); loop-free bodies skip the transform entirely. Workerd startup-seal semantics untouched — instrumentation runs inside the memo-miss path before new Function, so startup warms compile the instrumented body and a post-seal miss still refuses with CodegenStartupOnlyError before any parse. Injected fragments carry no newlines (stack line numbers survive) and identifiers are collision-checked against the body text.
  • Scope v1 deliberately: loop back-edges only — no recursion instrumentation (stack overflow self-contains), no async coverage (await-forever doesn't block the tick), no per-op cost accounting.
  • BREAKING: terrain marks become named-target verbs — the bulk marks clobber is unrepresentable (jacob's bless, thread 1785200291; the oak-path/pond specimen, dump 2243de5f: one patchTerrain({ marks }) call silently deleted every mark it didn't name, verified green by a single-key read-back). Three top-level ObjectAPI methods — addMark(key, mark, place?) (add-or-replace one), updateMark(key, patch, place?) (deep-merge into one; throws on an absent key with the add/update asymmetry teach), removeMark(key | key[], place?) (delete by name; idempotent on missing keys) — all riding patchTerrain's spec-write core (same place resolution, validation, row commit, no-op verdict, persist stream). No expression in this vocabulary can touch a mark it doesn't name.
  • patchTerrain({ marks }) as a same-kind operation throws the retirement teach, naming the casualties mechanically ([Tome] patchTerrain(): "marks" replaces the whole mark map — this call would delete N marks you didn't name (…). Use addMark(key, mark) / updateMark(key, patch) / removeMark(key). Rebuilding the set? removeMark the old keys first, then addMark the new ones. To wipe the map deliberately: removeMark([…]). — both key lists BOUNDED: casualties coalesce past 8 named, and a >8-key map's wipe hint self-computes via removeMark(Object.keys(api.getSpec("places.<id>.terrain.marks"))) so it stays a teach, not an 18k-char wall) — the objects-array refusal law, extended to the sibling collection. The carve-out is the authoring create: a kind-switching patch (and a first kind over a markless base) replaces terrain wholesale, so marks there has no prior map to clobber and keeps working. marks: null and the addMarks/removeMarks patch sub-keys (both accepted shapes) are deleted with errors naming the successors.
  • The persisted wire goes granular: mark verbs record addMarks: [{key, value}] / removeMarks: [keys] ops instead of echoing the merged map as a bulk marks replacement (the old buildPersistedTerrainPatch behavior) — the durable fold now merges disjoint concurrent mark writers instead of last-map-wins, and an engine carrying the verbs can never put a same-kind bulk replacement on the wire.
  • Both folds enforce the same named-target law via shared @spawn/tome-schemas primitives (terrainMarksReplacementCasualties, isTerrainMarksRemovalEcho, terrainMarksBulkReplaceRetiredMessage): kiln's durable gate throws the identical retirement message on a same-kind bulk replacement that deletes marks it didn't name; cf-kernel's spec-mutation fold skips that class (no error channel — never destructive). Pre-verb engines stay compatible through mechanical classification, not version sniffing: their persist layer echoed the full merged map for every mark op, so an add/update arrives as a superset (deletes nothing unnamed — folds), a removeMarks arrives as a pure-erasure echo (base echo minus removed keys, or null — folds), and only the clobber class (writes riding unnamed deletions) is refused. Kiln's addMarks wire parsing narrows to the canonical {key, value} array the engine emits (the record/single-entry shapes had zero wire producers).
  • Teach-side swap in the same change: the always-on <terrain> block teaches the three verbs where the bulk marks?: field sat alone; _examples river/ocean rewritten to addMark; heightmap-terrain / voxel-terrain / structures / water-and-swimming skills and the api-reference regenerate to the verbs; studio-chat's run_script teach and the api.addMarks phantom-lint entry now point at addMark.
  • Cross-player effect audiences: the silent lie becomes a named teaching fault — phase one is self-only (jacob's ruling, #inventors 1785207433: "Cross-player audiences are structurally dead under relay — two independent verified blocks. Phase one is self-only or it lies."). In client-auth multiplayer every effect call executes on a CLIENT (the acting player's machine, the place host, or the relay exec host), and a cross-player audience cannot be honored from there — two independent structural blocks: (1) EMIT-SIDE, the fan-out cannot see or address the room's other players (SessionOwner replicates owner-only and is server-stamped — session.ts — so the nearby/place/all loops in object-api.ts enumerate exactly {self}, and rows placed on a remote player's entity die at the client-auth upload envelope, runtime-client.ts getClientAuthUploadView); (2) WIRE-SIDE, the room has no per-player delivery channel (relay fan-out is broadcast; the owner-replicated carriers TomeJuiceTargeted/TomePlayerJuiceState are stripped at every peer reader — relay-peer-apply.ts stripPeerInvisibleRows / PEER_VISIBLE_REPLICATE_MODES, the law that closed the 2026-07-23 session/owner cross-wire). Previously an explicit nearby/place/all/other-player audience silently collapsed to self (the exact screenShake/vignette-with-nearby break the ruling names). Now resolveJuiceEventAudience/resolvePlayerStateAudience route every explicit audience through teachCrossPlayerAudienceSelfOnly: in client-auth multiplayer client worlds it records a named teaching fault on the getLogs rail (mutationWarn — "audience 'nearby' can't reach other players yet — effects are self-only in multiplayer for now", with the per-space action: world-anchored effects say drop-the-option since AOI already reaches everyone; screen/state effects say call-it-from-each-player's-own-trigger). Delivery is unchanged — the self half still applies; player audiences that resolve to the local session player (the ambient hook pin's own shape) never teach; server worlds (kernel-tier DO rooms, where the fan-out genuinely works) and singleplayer (trivially honest) never teach. Docs narrowed in the same change: the Audience @tomeapi block (types.ts), the generator's audience-defaults teaching block (generate-tome-api-docs.ts → api-reference §Audience defaults), and game-feel.md's manager-juice gotcha now teach the self-only phase-one contract. Runtime-fault-only compat: the Audience type keeps its members (existing specs carry nearby/place/all; scripts are untyped at runtime) — the narrowing is contract + fault, not a type break.
  • The lifetime reaper of last resort now fires on a successorless-HELD seat whose sitting host is suspension-stamped (fixes the warm-room-leak regression the successorless hold (ledger 1788, #11256) reintroduced for the never-returning-solo class). reapsExpiredLifetimeHere's server branch keyed on "place unhosted" (getPlaceHostEntry === undefined); the hold keeps the entry — same clientId, same epoch, "frozen-in-fact exactly like unhosted: nothing simulates a held place" — so a solo room whose owner F5'd or alt-tabbed away forever deferred every api.spawn({ lifetime }) reap indefinitely: the exact ledger-#732 warm-room ghost the deadline component was built to kill. "Hosted" for reap purposes now means ACTIVELY SIMULATING: the server also reaps the paused remainder when the seat's sitting host is suspension-stamped (the replicated word for "this session simulates nothing" — the same boundary that classifies its envelope into the remainder; the drain refuses a stamped session's uploads wholesale and any resume re-bases from a reset, so the reap can never race a valid write). In multi-client rooms a stamped host is re-elected away the same reconcile tick, so the new clause effectively fires only on the held solo room. The unstamped hold flavors (silent, off-Streaming past the retention ceiling, a god-parked detach) deliberately still defer — no authority boundary has been declared for them and each has a bounded exit. The hold's own semantics are untouched (all #11256 pins green); the GHOST solo e2e now asserts BOTH truths: seat held AND server reaps at one-tick precision.
  • properties.-prefix forgiveness in normalizePropertyKey (tome/api/properties.ts): setObjectProperty(id, "properties.light.intensity", …) — the spelling authors reach for because the spec nests object properties under properties — now strips the prefix and resolves identically to the bare key, silently, the same posture as the existing position → feetPosition alias. Kills the unknown property "properties.light.intensity" — call ignored class for Savi, wisps, mods, and humans at once (ravi's promotion-audit receipts, ×2 in staging events). One normalization site covers set/get/batch/dotted paths; a bare properties key (no dot) still rides the unknown-property teaching rail.
  • Fog numeric ranges warn instead of clamping silently (tome/api/dead-write-warnings.ts, new fogRangeClampRule row in the atmosphere table): a patchAtmosphere fog patch whose merged state the renderer would clamp now warns in the result channel naming the key, the given value, and the allowed range — linear far <= near (renderer clamps far to near + 1e-4 → hard fog wall at near instead of a gradient; atmosphere-sync's unauthored defaults near ?? 100 / far ?? 500 participate the way they render) and exp2 density < 0 (the shader squares density, so a negative renders exactly like its absolute value). Warn-and-clamp, deliberately not a reject: a latest-version-per-app scan of prod game_specs (2026-07-27) found 2 live specs carrying far == near linear fog that render through the clamp today — a hard reject would fault them on their next authored patch, and patchAtmosphere's told-success ⇒ readable-back contract stays intact (the doc keeps the authored values; the renderer clamps at its own boundary). Judged only when the patch touches a range key (kind/near/far/density); non-finite values remain applyFog's coercion lane.
  • Regression coverage red→green in __tests__/object-api.test.ts (prefix resolves like the bare key, composes with the position alias, bare properties still teaches) and __tests__/dead-write-warnings.test.ts (both range rows, default participation, silence for in-range/untouched/non-finite, plus an e2e pinning the warn rides the green receipt on the real verb).
  • player/-prefixed owners can now address their own spawned children (tome/api/object-api.ts resolveObjectIdFromEntity; DUST LINE lag dig, dump 01b9df / report b955324d §3A): the player/ early return resolved exact entity ids only and preempted the local-child scope every other id form gets, so an entity whose OWN id starts with player/ — every player rig — could never resolve api.id + suffix back to the parts it spawned with api.spawn(api.id + suffix, { parent: api.id }). The children existed (the dump's teach line showed hasChild: true while the call dropped); the writes were discarded at 497/session in DUST LINE and the flood repeatedly misdirected that game's own debugging journal. On an exact-id miss the resolver now falls through to resolveLocalChildId — local-child scope only; every other missed player/… id stays null, and live exact ids still win first. Red-first regression pins the rig pattern end-to-end (spawn → resolve → setObjectProperty lands) plus the exact-first and stays-null contracts.
  • The authored-leaf-id fallback no longer walks the world per miss (same resolver tail + findLeafIdCandidates; §3B): any slash-free id that missed every cheap scope triggered a full world.query(TomeChildren) walk — endsWith + hasEntity per listed child, O(world) per miss (~65 µs on a 2,203-entity DUST LINE-shaped world; a script looping over stored-but-dead ids paid for the whole world on every dropped call). Both sites now read a leaf→ids index (tome/leaf-id-index, classified re-derive at the exec boundary) maintained by TomeChildren component events — which fire synchronously on every write path (ObjectAPI spawn/destroy/reparent, interpreter, replicated deltas) and on parent despawn. Duplicate entries are refcounts so out-of-order replicated childIds writes (new parent's list landing before the old parent's removal) can't orphan a live listing. Lookup semantics are byte-equivalent to the scan: same per-child hasEntity liveness guard, same unique-leaf match, same ambiguity refusal. Micro-bench on the 2,203-entity world: miss 65.06 µs → 0.53 µs, leaf-hit 64.65 µs → 0.56 µs (~120×). Churn tests pin spawn/destroy/respawn-elsewhere/reparent/parent-despawn/out-of-order-replication equivalence.
  • Sim-tick attribution in the debug dump — the instrument that ends the blind-perf-pass class (b955324d §5: DUST LINE's sim ran 11× over budget, two structural perf passes landed without curing it, and the dump carried ONE aggregate sim number — "the profile is structural, not a stopwatch"). The #631 simTiming payload now carries per-system AND per-behavior-script milliseconds over the same ~45-tick window, on both sides: the client worker's perf-rollup block and the server's input-stats capture each gain simTiming.attribution = { ticks, entries: [{ name, kind: "system" | "script", p50Ms, p95Ms, maxMs, totalMs }] }. No new clocks on the hot path — the collector (engine/runtime/core/sim-attribution.ts) routes measurements the engine already takes: Diagnostics.runWrapped/runTimed gained an onSample tap (every system, every tick, ecs/commit and changelog/prune included), and behavior-update reports the per-entity elapsed it already measures for the watchdog through the new SimAttributionResource, keyed by the composed behavior's normalized update-bearing script refs (dust-storm.js — spec-declared objects included, which carry no behaviorRefs; multi-script entities join their refs; non-authority multiplayer clients stay clock-free and simply ship no script rows). Client-side collection is gated to the MAIN simulation phase — exactly the window the client tick ring measures, so the two blocks cross-read. Payload bounded by construction: top 12 systems + top 8 scripts by window-total ms, each kind's remainder folded into "(other)"; tracked-name caps (128 systems / 32 scripts) route overflow into the same fold. Kiln retains the block through the existing simTiming lane (re-validated + re-bounded, session-captures.ts) and the dump summary renders "Sim Attribution — top by p50 ms per tick" beside the Sim Tick Timing rings, script rows as a sublist under the behavior-update system row they subdivide. Instrument overhead measured (scripts/bench-sim-attribution.ts, the #11220 bar): 28µs/tick at a deliberately heavy 64-systems + 300-script-samples scale = 0.085% of the 33.3ms tick budget, runWrapped tap delta unmeasurable — noise floor, so the instrument ships unsampled.
  • Sole-candidate place-host churn: a place is never unhosted out from under a live connection — the successorless hold (ledger 1788, dump b0298916 / incident family across ≥7 apps: in a room whose place has ONE live client — the creator, also its host — every transport/visibility boundary deleted the TomePlaceHosts entry because electable.length === 0, then re-elected at a bumped epoch once the client recovered; every host write uploaded around the boundary died silently at the drain's stale-epoch gate on the unhosted branch (tableEpoch:null), and because uploads are deltas against the client's own last-sent state the lost rows were never re-sent — one-shot script writes (entered/health init) forked the room's copy from the client's truth permanently; F5 reproduced ~100%, room restart + fresh join was the only cure). The fix is election-side, at the unhost branch of reconcilePlace: when retention declines the sitting host for a reason its connection record survives — detached inside the 20s disconnect grace (F5/pagehide), suspension-stamped (alt-tab past the takeover grace), sustained silence, or off-Streaming past the retention ceiling — and NO other candidate is electable, the entry is HELD: same clientId, same epoch, no revoke, frozen-in-fact exactly like unhosted (nothing simulates a held place; server-write-forward only forwards to Streaming connections; the drain's suspension/ingress gates refuse a non-live host's writes). The returning client's uploads validate the moment it recovers — the resume reset carries the held table at the epoch the client already adopted, and an F5 rejoin adopts hostship on its join's OWN reset with no unhosted gap. The hold never outranks a real successor (any electable candidate takes the place through ordinary election, same epoch bump as before) and never shelters a genuine departure (connection gone/tearing down, disconnect-grace expiry, place-leave, attach-time opt-out unhost exactly as before, now with the retention-decline reason on the netcode.place_host.unhosted line — the solo-churn field dumps carried six drop windows and zero election-side evidence). New ops receipts: netcode.place_host.successorless_hold / successorless_hold_released (one pair per episode, with reason, heldTicks, and outcome) and a successorlessHolds tenure counter. This also deletes the precondition of the #11237 returning-tab wedge for the un-stamped (god-parked) sole population: the "dead" frozen table claim stays live, so writes land directly and the forced re-seat rail is left to the moved-table wedges it was built for.
  • UI render heartbeat rides the debug dump — "frozen authored UI + all-green instruments" stops being representable (ledger 1786, dig 72d4ee01: a creator stared at an unclosable shop menu while Savi's live bisect proved the close click ARRIVED and flipped state server-side; the dump summary carried sim-tick/renderer/network/scene-gate and NOTHING about UI render, and run_ui_script cannot reach the nested cross-origin realm). The HUD pipeline stage ledger (c1762332's instrument) gains the two legs the incident class needs and then surfaces in the dump: (1) a rejected stage — the apply-render.ts invalid-inline-handler early return, which keeps the OLD DOM painted on purpose, now counts itself via a new ui.rejected frame receipt (sig echo + htmlLen + teaching-message head; protocol spec + 120/s cap mirroring ui.applied, relayed uiRejected by the realm host, recorded on the ledger beside applied); (2) a client-side ui.fault counter — uiLifecycleFault arrivals count on the ledger at the worker choke point BEFORE the behavior-fault rail forward, so a fault count that moved while getLogs stayed empty names the rail's dead leg instead of reading as "no fault fired". The whole ledger (compile state, sent/deduped/applied/rejected sig-stamped stage breadcrumbs, faults) now rides the 15s perf rollup as payload.uiRender → kiln session-captures retains it (re-validated, bounded) → the debug-dump diagnostics carry it → the dump summary renders a per-plane UI render: line beside Scene gate, including the derived stale-paint tell (⚠ last sent ≠ last applied — the painted DOM is NOT the last render). Fault-rail verdict from the same commission: the DM/log path is intact end-to-end in code (frame ui.fault → host → uiLifecycleFault → sendClientBehaviorFault → engine.diagnostic, allowlisted behavior-fault → runtime log + Savi DM) — the incident session's empty getLogs is evidence the reject class never fired on that client, which the heartbeat now proves or refutes from the dump alone.
  • Host-migration desync: the forced re-seat rail admits Suspended senders — the unhosted-epoch silence is healed, not just counted (2026-07-27 field family, verdict 5c2a90ee: four specimens on the unhosted-table branch — tableEntry === undefined, "the place is no longer hosted" — one 204s / 31,482-write window; partner gutterbt's Castle Clash session, dumps c870e5ce/7ec9cc8b: a tab goes inactive, the place host migrates/unhosts, the returning client keeps stamping its frozen table copy and every host write drops silently, unrecovered by room restart or reload). The wedge was a false premise at the rail's connection gate: it declined any non-Streaming sender on the claim that its uploads "never reach this drain" — false for plain Suspended since the ledger-1572 ingress admit. A session the server still classifies Suspended while its tab is actually back and simulating had NO exit: ingress admits its uploads, the stale-epoch gate drops every host write, the truth echo is inert on the unhosted branch (v1 null-truth), egress skips the connection wholesale, election cannot touch it, and the one rail built for exactly this wedge (ledger 1257) declined per-message forever — reseatsDeclined with projectionState: Suspended was the counted-but-unhealed signature. The gate now tracks the ingress admit exactly: Streaming and Suspended-without-rebase-debt force (same floors, same cooldown, same NeedsReset directive through the production make-before-break reset rail); NeedsReset, Suspended-mid-rebase, and detached still decline (a rebase is in flight, or the directive cannot be delivered). Session-STAMPED zombies never reach this gate (the isSessionSuspended wholesale drop runs earlier), so the admit can never fight the suspension takeover. A genuinely-hidden tab discards the snapshot until it returns — bounded by the reseat cooldown and the reset-ack hold backoff — replacing an UNBOUNDED silent write-floor. Red-first e2e pins the whole field arc: god-parked creator (the un-stamped suspension population), tab hides, place unhosts on silence, tab returns without the server ever hearing the Active, the client stamps the dead claim for a sustained window → one forced re-seat, the client rebases onto the unhosted truth, election re-hosts it at the bumped epoch, and its writes land where they silently dropped.
  • fx VM algebra completion: sin, cos, atan2, fract, floor, step, smoothstep, abs, mod, pow, sqrt, dot as field ops on both backends (branchless WGSL, CPU/GPU edge semantics pinned: totalized pow(|x|,y), atan2 x=0 column defined), plus channelIndex() for exact per-particle spawn ordinals and .yaw() sugar (atan2(−z, x), the ground-plane spin convention)
  • bindings.frame: flipbook as a field over the sprite sink's layers (floor()ed, clamped; unset keeps the stable per-life random pick bit-for-bit); sheet("…?animated=CxR") slices one uploaded atlas into layers at pack build; fx texture packs now carry full mip chains with trilinear sampling
  • pos:/vel: authored kinematics (integrator bypass; attr("p") reads last tick); entity(id) live-transform vec3 fields (≤4 links per effect, dead links freeze at last sample)
  • sprite sink: soft: meters depth fade against the viewport share; blend:"distort" (color.xy → screen-space refraction offset, alpha = mix); align:"ground" honors stretch as a plain aspect factor along an authored rotation binding
  • floor:"bounce" with per-particle restitution (bounce: number | spawn-time field); floor:"die" death records now carry age-at-impact on BOTH backends (was end-of-life on CPU)
  • one dynamic light per population now follows the alive-particle centroid on the GPU backend too (anchor stand-in path deleted); light radius accepts a field
  • B12 event rail: api.pulseFx(id, name, { at?, data? }) + spawn: { when: { event: "pulse", name } } arms — integer counters on the replicated FxEmitter, per-tick coalescing, late-join snapshot-then-deltas; world-anchored juice kinds are simulator-elected at the emit funnel (the doubled-VFX class is structurally unrepresentable; realm:"server" is a compat no-op)
  • decal: [start, end] lifetime ramps on scalar fields, attachTo alias, anchor references resolve like every sibling ("player", place-scoped, child ids); part materials gain dissolve: 0..1 (perlin-erode cutaway, emissive edge tint, blended lane)
  • BREAKING (vfxContractV2 games only): slash throws on invalid args and requires size in meters, defaults to a horizontal arc at the caller's yaw with a seeded sweep side; decal rotation in degrees; decal size pairs are ramps
  • slash edgeBody preset product (20 names) retired to an untaught legacy lowering; the 14 particleBurst presets frozen into a lowering table; oklch color strings accepted spec-wide in fx fields and juice colors (slash's ride the v2 gate — legacy slash colors stay number-only, exactly as shipped)
  • governance: slash/shockwave/decal instances register in the fx census and pressure ladder with honest cost weights (distort 2×, soft +0.5, slash 3×, shockwave 2×); new fx-bespoke-clamp diagnostic
  • 27 confirmed bug fixes from the adversarially-verified VFX scout sweep, each regression-pinned — highlights: space:"local" localP force fields correct and CPU/GPU-identical at non-origin anchors; inheritVelocity works platform-wide (anchor velocity derived at the sampling seam); duration'd highlight/pushLook cleanup survives the calling entity's death; GPU rate-spawn derivation is closed-form (rate 1e9 costs O(1)); ribbon segments validated/capped; live param-patch reshapes take effect on the CPU backend (reset-on-grow, matching GPU); fx census/expected-value folds never certify non-monotone ops or fold taught idioms to 0

Engine v5.2.6

Released July 27, 2026

  • Fixed a failure mode where a player could sink into (or bob on) one patch of ground forever with no explanation: if the game client repeatedly fails to build the ground under a player, it now stops silently retrying, tells Savi exactly which chunk failed and why, and shows the affected player an honest "reload to rejoin" message instead of leaving them stuck.
  • Fixed a bug where models spawned by scripts with a layout.maxExtents size limit could render at their raw file size forever — giant enemies, or tiny ones invisible under a floating name tag — until the room was reloaded. Size limits now apply as soon as the model's real dimensions are known, and a size already computed elsewhere is never discarded while a client is still loading.
  • If your multiplayer game polls getOverlapping()/getTouching()/isTouching() from server-side code, Savi now gets told once — with the actual reason (the multiplayer server doesn't simulate physics) and the fix (read contacts where the entity simulates and share results as state) — instead of the game silently answering "no contacts" forever behind a generic warning.
  • When a game spawns more physics props than the simulation can carry (piles of falling crates, rain-of-weapons spawners), Savi now gets told once — with the actual numbers (how many dynamic bodies, how far over the tick budget) and the fix (despawn or pool spawned props, cap spawner counts) — instead of the room silently grinding to a slideshow with no diagnostics anywhere.
  • Fixed mouse-only and touch-only games permanently losing input after interacting with the chat overlay: if the engine's input mode got stuck thinking the overlay was open, keyboard games self-healed but pointer games stayed dead until a page refresh. Clicks and taps now trigger the same self-heal keys always had.
  • Fixed mouse clicks doing nothing in games built on the new engine UI layer — every world click was being cancelled by a focus bounce before your onInput code could see it (keyboard bindings were unaffected, which is why key controls kept working).
  • Fixed right-click over the game world popping the browser's context menu instead of reaching your game, in normal play and god mode.
  • Fixed multiplayer rooms losing their server-side scheduling after an engine upgrade: crons, lifecycle hooks, shared storage writes from crons, and custom jobs all worked on 5.2.4 and silently broke on 5.2.5 (storage errors like "A player can only read/write its own storage", jobs that never came back). Server-side execution stays on for these rooms now.
  • Custom jobs submitted where no executor exists now fail immediately with an error that says why and where the job runs, instead of hanging forever.
  • Objects placed by ground height in a place whose terrain isn't set up no longer get silently pinned underground forever: the engine refuses to invent a ground of 0, says so once in the logs, and re-seats the object automatically the moment the place's terrain arrives.
  • In worlds with legacy sculpted terrain, "place on the ground" now means the ground you actually see — sculpt edits count for placement exactly like they count for walking.
  • getTerrainHeight returns null instead of a misleading 0 when a place has no terrain — so scripts can tell "flat ground at 0" apart from "no ground here at all".
  • Physics calls that can't work where you put them now tell Savi why, once, with the fix — instead of silently doing nothing. If your enemies never moved or your traps never triggered, this is the family that now explains itself.
  • Fixed multiplayer rooms losing cron autosaves and disconnect saves that touch shared storage: on relay-hosted rooms, storage writes from crons and lifecycle hooks failed with "A player can only read/write its own storage" even though the same game worked on server-hosted rooms. Crons and lifecycle hooks now carry their server-context storage authority regardless of where the room makes them run; player storage stays protected from other players exactly as before.
  • Your world's look can now use multi-pass effects: motion trails, glow that accumulates over time, dreamy feedback loops, and blur chains — all from the same look scripts Savi already writes.
  • Generated characters and models stopped turning near-black in shade: models whose material never said how metallic it was were being treated as polished mirrors. They now read as normal matte surfaces — fabric, skin, and painted detail show again, including models generated over the last week and a half.
  • Placing things on blocky (voxel) terrain now puts their feet exactly on top of the ground — no more objects sinking into blocks or hovering over them, especially in 2D worlds with small block sizes.
  • Terrain queries far from any player now say "that part of the world isn't loaded here" instead of pretending it's empty air — so a probe 100 m away can't convince anyone (Savi included) that solid ground never generated. The log line that appears alongside explains where probing works and why.
  • Characters can now ragdoll: api.ragdoll(enemyId, { impulse: [300, 150, 0] }) flips a rigged character into loose physics — it crumples with its running momentum, tumbles off ledges, and settles into a heap. api.unragdoll() stands it back up with a smooth blend into animation (no pose snap). Works on players and NPCs in mantle-physics worlds; ragdolls freeze solid after settling so a battlefield of bodies stays fast.
  • In first-person games the player's shadow no longer vanishes: hiding your own body from the camera used to erase its shadow too — the shadow now stays on the ground while the body stays out of view.
  • First-person weapons Savi builds now line up by construction: iron sights land on the crosshair from the weapon's own declared geometry instead of guessed offsets, and hands are authored in the weapon's frame so they can't drift off the grip.
  • Savi can now actually see the code-authored models she builds for you (scripted and custom geometry) in her isolated preview before they enter your world — those pieces used to be skipped, so hand-crafted props were built blind.
  • Signs keep their authored size and text styling everywhere: updating a sign's text (like a score or percent counter) can no longer reset it to a giant default board after the room restarts or changes hosts.
  • "Delete everything and start me over" now works in one clean step: when you explicitly ask for a full wipe, Savi can reset your world to a fresh starter instantly instead of dismantling it piece by piece. Switching starters while keeping your work stays protected exactly as before.

technical notes

  • Ledger #597's failure-visibility cure now exists on the CLIENT chunk-build path (ledger 1771 — the same half-cure class as the #10902/#10924 blur family: a fix that landed on one of two symmetric paths). Pre-fix, every client build failure (job error/canceled/stale, stuck-past-deadline) was consumed silently, re-marked, and resubmitted every tick forever — no reason, no counter, no backoff, no terminal state; cal's support chunk looped 3 hours emitting nothing (report 2b9742c1). Four pieces, mirroring the server's #597 vocabulary:
    • Failure reasons: every failed client chunk build logs the actual reason via tomeLogger ([terrain/client-build] chunk build failed: … consecutiveFailures=N retryBackoffTicks=… reason=…), first failure immediately, re-warn once a minute.
    • Counter + backoff: per-chunk consecutive-failure count with the server's doubling backoff (serverChunkBuildRetryBackoffTicks, 1s→60s) gating resubmission in the Phase-2 scan; a completed build resets it.
    • Terminal park (park-don't-hammer): after 8 consecutive failures of UNCHANGED inputs the chunk parks on its inputs hash with a logged verdict — rescue re-requests and place-wide dirty sweeps are absorbed instead of hammering the proven-failing build; the park lifts the moment the chunk's inputs actually change.
    • Scene-critical escalation (the #10927 wall pattern): a terminal park on the LOCAL player's support chunk — they're standing in ground this client provably cannot build, sinking into the rescue's containment loop — emits the new terrain-collider-build-parked engine diagnostic (server runtime log + DM + stdout breadcrumb, naming chunk and reason) and raises the player-facing reload wall through the loading-state error channel (one per world). Fires both at park time and when a player later walks into an already-parked chunk.
  • Rescue-remediation attempts survive across episodes (the report's second bug: attempt=1 three times across 3 hours). The remediation entry no longer resets on a firing gap — the rescue pauses when the player leaves, not when the chunk heals — it resets only when the side actually observes the chunk's collider ready (client: collider readiness counts; server: done build + intact collider plane). The #597 honesty bound (escalation after 3 attempts) is now reachable for the episodic failure shape, on both sides symmetrically.
  • Server build path (server-terrain-system.ts) untouched — byte-identical.
  • Coverage, red-first on the pre-fix build (93 submissions in 93 ticks vs 3 with backoff): src/engine/features/terrain/__tests__/client-chunk-build-failure.test.ts (reason logging, stuck reason, backoff cadence 1/32/93, reset-on-success, terminal park + absorb + lift-on-inputs-change, active-chunk escalation + one-shot wall, walk-into-parked-chunk escalation, no escalation for unoccupied parks) and chunk-rescue.test.ts (attempts 1→2→escalate across gapped episodes on both sides; observed-ready resets).
  • layout.maxExtents can no longer die permanently on runtime-spawned GLB children (ledger 1770, P1 — ITERO's 8m-tall zombies / invisible speck enemies with floating HP labels, physics staying correct throughout). Two structural halves:
    • Park without delete. geometry-fit-solve no longer removes GeometryScale when layout + model are present but the model's bounds are unresolved in THIS realm. GeometryScale is a replicated component every realm independently re-derives — deleting on "I don't know yet" turned one cold BoundsRegistry in one realm into a permanent raw-native render. Ignorance now holds any present factor (replicated or previously solved) and parks on the bounds waiter; deletion stays reserved for genuinely-removed fit inputs (layout removed — the interpreter's design-intended strip — or model removed).
    • The wake. The onModelAnalyzed writer (game module serverMount) now lands analyzed bounds through the shared writeBoundsToSpec writer instead of a hand-rolled spec merge. That gains the BoundsRegistry side-write (setBounds → onBoundsReady), so entities parked by geometry-fit-solve re-solve the moment analysis delivers bounds — previously they woke only on the next full applySpec, i.e. possibly never. It also gains the TomeUnfilteredSpecResource merge the hand-rolled path skipped. Still deliberately triangle-less, so bounds-prefetch's completeness gate keeps the glb-bounds derive eligible.
  • Regression coverage, red-first on the pre-fix build: src/game/__tests__/geometry-fit-analyzed-bounds-wake.test.ts (the ITERO shape end to end — production game module, singleplayer client-authority spec via the TomeSpec carrier, behavior objectApi.spawn child with model + layout.maxExtents + scale, cold registry, bounds arriving via the analysis listener only after the entity parks) and src/tome/__tests__/geometry-fit-solve.test.ts (park-holds-replicated-factor, park-then-wake solve, layout/model removal still delete).
  • The overlap/touch pair reads (getOverlapping/getTouching/isTouching) join the server-realm loud-once teach rail (ledger 1763's class-phrase enumeration, completed — the raycast and velocity/impulse families rode it in #10315/#10888). The multiplayer server never mints a physics runtime (client-auth: mantle and rapier runtime creators are client-only systems), so server-run pair reads are structurally dead — empty answers per call, forever, with no replicated mirror to fall back on — while the old warn told a server behavior that reads are "unavailable in run_script; use behavior scripts", a redirect to exactly where it already was. Server-side the warn now names the mechanism and the redirect (read contacts where the entity simulates; replicate results as state/component writes), and one Savi error DM per (place, script) covers the whole three-verb family on its own notifyDmOnce key — a per-tick contact poll teaches exactly once instead of storming the log with zero DMs. The empty degrade, the mantle/rapier answer paths, the rate-limited warn trail, and every other realm's warn text are byte-identical.
  • physics/step joins the tick-budget watchdog — the population melt is no longer silent (ravi's probe 5703dcd7: falling dynamic props cross the half-tick budget between 400–800 bodies, and an 8000-body room ran 2.4Hz with ZERO diagnostics, because the watchdog sampled behavior update() brackets while physics/step is an engine system). The behavior watchdog (behavior-watchdog.ts) gains an engine-system lane watching the whole physics step bracket with the module's own conventions — half-tick budget, BEHAVIOR_BUDGET_STRIKES strictly consecutive separated over-budget ticks, the #443 strike-separation rule, the hidden-tab law — plus a lane-local self-measured stall grace (the behavior lane's inter-pass probe only arms on the authority; multiplayer clients, exactly where physics steps under client-auth, never run it). Sustained over-budget emits the same shape as behavior overruns: a physics-step-budget engine diagnostic naming the mechanism (dynamic-body count, awake count, step ms, tick budget) into the runtime log, one loud-once Savi DM naming the fix (~250 dynamic bodies comfortable, ~400 the ceiling, despawn/pool spawned props, cap spawners — with the #297 consent rule inline, since the named fix reduces live creator content), and a DD-visible server-console breadcrumb (under client-auth the melt costs the browser, not the container — every server-side signal stayed quiet by design). Deliberately diagnostics-only: no parking, no body governor — population is the author's lever and the honest ceiling is device-relative; the trip-time body census is the only added sweep (≤ once per 10s cooldown, never per tick).
  • Fixed pointer-only games permanently losing all mouse input when the input mode strands in "overlay" (ledger 1774, incident 36984a9f "Idle Homestead"): the mode is a kernel-side replica of parent overlay state over a lossy change-only postMessage channel, and the #233 self-heal (overlay-state refresh request + honest fallback to game) was keyed to eaten KEYDOWNS only — a mouse-only game never presses a key, so a stranded mode ate its world clicks forever (only a page refresh recovered, and it re-wedged). The healer's evidence now matches the overlay gate's diet: an eaten world-bound mouse press, touch tap, or frame-forwarded keydown feeds the same re-sync lane. A legitimately open overlay is never fought — the parent's answer to the refresh keeps the mode exactly where the parent says it belongs.
  • noteEatenGameplayKey renamed to noteEatenGameplayInput (it is no longer key-fed only). No wire changes: the fix reuses the existing spawn:kernel:overlay-state-refresh / parent:overlay:state lane.
  • Fixed every world click being wiped before behavior onInput on realm-architecture games (member #2 of the #10902 blur family; field receipts 0/1542 clicks vs 169/169 keys): a world click focuses the full-viewport data-tome-realm frame, the game window fires blur, and raw-capture's window-blur RESET landed in the same drain as the forwarded MOUSE_BUTTON_DOWN — then focusGameplayCanvas bounced focus back so every click re-fired the blur. Blur classification now lives in ONE shared predicate (realm-focus.ts isBlurIntoAuthoredRealm) consulted by both raw-capture and the pointer transport (#10902's private copy migrated); real blurs (other window/tab/devtools) still reset.
  • Eliminated the family at the source: a fall-through pointerdown in a realm frame (target <body>/<html> — the world's press) now preventDefaults, so focus never enters the frame on a world press and no host blur fires. Presses on authored UI keep native focus; the spec keeps click firing after a canceled pointerdown, so the pointer-lock gesture lane is untouched.
  • Fixed right-click over the world opening the browser's native context menu in both normal play and god mode (dump 259821ee): pre-realm the canvas node suppressed contextmenu, but inside the realm frame suppression only lived on the mount containers — fall-through right-clicks hit <body>. One document-level listener per frame now cancels the default; authored contextmenu handlers keep firing.
  • Relay sim tier is now an EXPLICIT host declaration — never an inherited default (ledger 1772, incident de79b468). Since #9786, EngineHostCore defaulted simTier to "relay" and the Bun container host passed no simTier, so the CONTAINER tier — a trusted in-process host that runs creator JS — silently declared "nothing server-side executes" the moment a room upgraded past 5.2.4: crons forwarded to a player seat (whose shared-storage writes refuse with storage_unauthorized), lifecycle hooks skipped hostless, and spec.jobs submissions hung. The default is inverted: an undeclared host is "host-capable" (capability-preserving), and the RoomDO shell — the one host that IS the multiplayer relay tier — declares simTier: "relay" explicitly (SPAWN_SIM_TIER=host-capable stays the ops rollback knob; deployed DO behavior is byte-identical). A version promote can no longer act as a silent capability removal. Pinned by the slim-server manifest suite: an undeclared host registers the full host-capable allowlist, a relay-declaring host registers exactly the relay allowlist.
  • A custom (spec.jobs) job submission on a relay-tier room settles an immediate refusal instead of hanging forever. The relay RPC bridge classified non-builtin job ids as {kind:"pass"}, which fell into the relay egress dead-lane drop — no job.response ever arrived and awaitJob wedged silently. Non-builtin ids now settle ok:false immediately with a message naming the tier and where the job class actually runs ("custom job X cannot run on this room's relay tier — … crons and lifecycle hooks run on the resident host client, and a custom job runs on the client that submits it"), the same failNow shape as the bridge's other honest refusals. Red-first e2e coverage in relay-rpc-bridge.e2e.test.ts.
  • Placement height answers loudly or not at all (ledger 1773; digs 7ed968cf + 3694dc1f, cal's Final Abyss — vgw-35 parked 12.16m under its own ground because a baked Y fossilized a pre-steepening terrain). hasTerrain(placeId) === false now REFUSES: the samplers return null and warn [terrain] no definition installed for place X once per place per session, instead of silently answering 0 + offset. getTerrainDefinition with no placeId refuses instead of answering whichever place was first in the map (a stranger's ground). Every caller was audited to a chosen behavior: placements PARK at the bare offset with their terrain anchor armed, baking paths (spline, scatter, god-mode gestures) read refused ground as the park origin so { terrain: offset } round-trips exactly, and feature paths treat refusal as feature-off.
  • A parked placement re-resolves the moment its place's definition installs. The re-anchor system's watched version is now the ground's inputs — the per-chunk terrain:height field token PLUS a definition install token (terrainDefinitionInstallToken) — so an install by ANY arrival path (spec apply, first-residency expansion, client replication) revisits the parked answer. Before, the gate was the field version alone and an install never moved it: parked meant permanent.
  • One ground truth for the drawn and the placed surface. Heightmap placement sampling (sampleReplicatedTerrainHeight, the y: { terrain: N } pin lane, api.getTerrainHeight) now folds in the same heightmap sculpt edits the chunk builds realize (applyHeightSculptEdits, extracted from jobs/chunk-build.ts into one shared module) — a sculpted world's placements land on the ground players see and collide with, not the pre-sculpt generator surface.
  • The repair anti-pattern, as doctrine: any re-seat that writes an absolute Y is a future sink — the repair primitive is { terrain: N }, which follows every subsequent terrain change for free.
  • Velocity/impulse teach rail completed (ledger 1763, #10888 + #10945): every silent no-op arm in the server-realm contact/motion family now speaks once. setVelocity/applyImpulse on a client-realm body, and the pair-read family (getOverlapping/getTouching) polled from a server-realm manager, each emit one runtime-log diagnostic naming WHY the call can't work in that realm and WHERE to move the code (the shooter's onInput, a client behavior, or an authority-side handler) — instead of returning nothing and letting the script author debug a ghost. Same #597 vocabulary as the collider rails; episode-surviving counters; one report per script per condition.
  • A relay-tier room's forwarded crons and lifecycle hooks can write SHARED storage — the exemption keys on host-forwarded provenance, never on the seat and never on "any client may write shared" (ledger 1772 residual, incident de79b468; composes with #10934's explicit tier declaration). On relay tier, crons/lifecycle hooks execute on the resident place-host client, but that seat's shared-key storage writes refused with storage_unauthorized — authorizeForwardedStorage only knew the player-context law plus the singleplayer engine exception, so the taught persistence patterns (cron autosave to world/state, a departed player's disconnect save to user/<departed>/…) silently saved nothing on the tier that is about to become the prod default. Now the lifecycle/cron execution lane stamps its forwarded job submissions (lifecycleSubmitted — minted from hook-forward's marked ObjectAPI lane state, never from creator args, threaded job-dispatch → forwarding facade → wire), and the server honors the stamp only when its OWN facts corroborate it: effective sim tier is relay AND the sender is a currently named place host in the DO's replicated host table (the same table the fire sites target). A non-host client forging the stamp refuses exactly as before; the host's own unstamped player-context writes refuse exactly as before; singleplayer semantics (deliberate shared-key refusal, dig 93bab0de) are untouched — resolveWorldSimTier never answers relay for SP. Red-first coverage in job-forward.test.ts: resident-host cron CAN write shared while a stamped non-host player CANNOT, cross-player disconnect saves, anonymous hosts, tier/declaration gating, hostless rooms, and the end-to-end stamp mint through the wire parse.
  • Look scripts gain offscreen render targets: ctx.target(name, { scale | width+height, format }) returns a write-only descriptor with .previous (last frame's contents — touching it arms a ping-pong pair) and .size (live vec2, patched per frame). Default format rgba16float; rgba8unorm option.
  • ctx.pass(target, (uv) => colorNode) renders a fullscreen pass into a target and RETURNS the readable texture node (sample/sampleLevel/.size); declaration order IS execution order, and eager graph recording makes read-before-write unrepresentable (a target has no readable surface until a pass hands one back). Passes read ctx.scene/depth/viewZ/params/textures and earlier passes' returned nodes. Passes attach between the composite and look.present at one call site (LookPass.declareScriptPasses).
  • Budget rails with teaching errors naming the cap: 8 targets, 16 passes, 2048px fixed-size cap, scale ≤ 1, 256 MiB total target memory.
  • Closes the documented TODO(lume-postfx-rtt) boundary — multi-pass effects (separable blur chains, feedback trails, radiance-cascade-style accumulation) are now expressible in plain look scripts.
  • GLB parse (ledger 1776, ravi dig b28329ec): a material that authors neither a metallicRoughnessTexture nor a metallicFactor — the raw Meshy 1-texture export that 86% of humanoids ship since the bake removal (#9425/#9440 + #10396) — now defaults metallic 0 / roughness 0.8 instead of the glTF spec's silent metallic=1.0 (near-black in shade under lume; shading.ts's own comment names the hazard). Authored values honored verbatim: an explicit metallicFactor (even 1.0) parses unchanged, mR-map materials keep spec factor-1 (baked assets byte-identical), authored roughnessFactor always wins. Aligns the parse-side default with lume's own DEFAULT_MATERIAL_SOURCE (unauthored = dielectric) — one opinion, one home. Heals the 10 days of already-minted raw assets at load, no backfill. Fixture-tested across 6 material shapes; content-verified on the farmer-woman raw/baked boundary pair. (Re-landed on tiger's KEEP after the before/after pair — #inventors 1785101325.)
  • Fixed feet-anchored placement (feetPosition without y / y: { terrain: N }) and api.getTerrainHeight on voxel terrain: the ground sampler pre-floored x/z to whole meters, so at cellSize < 1 fractional positions sampled the neighboring column and baked its height (objects sank into or floated over slope cells).
  • 2D voxel places: the height sampler now reads the gameplay slice's cell layer (THE SLICE CONTRACT) — an object's own z is cosmetic depth and no longer selects a phantom column. One correction in the sampler, shared by feet anchors and getTerrainHeight (which previously corrected only its own reads).
  • A voxel column with no supporting surface now refuses (null) instead of fabricating verticalRange.min — terrain-relative placements park at their bare offset with the anchor armed and re-seat when ground appears, matching the no-terrain-definition convention (ledger 1773).
  • Voxel reads answer loudly or say they can't (the #10949 loud-or-nothing doctrine, chunk-residency edition; dump 8a692ef1 / anatomy 011ef557: Savi's isVoxelSolid(0,30,0) read un-built rock 90–150 m from the player as air BY DESIGN, she announced "the asteroid never generated" and chased a generator bug that didn't exist). VoxelMaterialLookupResource.getCell now returns the third state "unloaded" when a cell sits inside the loadable chunk band (the streaming lattice enumeration over verticalRange) but has no resident grid AND no edit-overlay entry; outside the band air stays air (no chunk can ever exist there — known by construction). Fresh grids, the edit overlay, and stale-grid reads are untouched.
  • Every consumer audited to a chosen behavior (the type union forces the audit): ObjectAPI probes surface it honestly + one teach-rail warn per (place, script) shared across the whole family (isVoxelSolid → null, getVoxelMaterial → "unloaded", raycastVoxel/queryVoxels/getWaterLevelAt report unconfirmed scans via onUnloadedCell, setVoxelState/interactWithVoxel/breakVoxel refuse); block-hook local reads treat an unloaded neighbor as absent (writes still land through the edit log + overlay); NPC line-of-sight blocks only on CONFIRMED solid; the stuck-body rescue fires only on confirmed solid and never teleports into unconfirmed clearance; block ticks skip unconfirmable cells; the renderer pointer-raycast bridge deliberately keeps pass-through (picks target what renders, client-resident by construction).
  • api.ragdoll(id?, opts?) / api.unragdoll(id?, opts?) (jacob's rq r-5d43631f): a rigged character flips from animation to physics and back — the primitive, not a pipeline; Savi owns the choreography. The skeleton's bones become dynamic mantle capsule bodies (dims from bind-pose bone lengths, sub-scale bones fold into parents, mass by volume share) joined along the skeleton's own hierarchy — hinges derived on straight chains (knees/elbows), balls at branch points (hips/shoulders), no humanoid bone-name table. Initial velocities come from the animation pose delta across the arm tick, so a running character crumples with momentum; opts.impulse adds a hit direction.
  • Joint-limit derivation is ONE swappable module (engine/physics/ragdoll/joint-limits.ts) — Jure holds physics design word on ragdoll constraints, and his steer (signed hinge ranges, cone limits, stiffness ladders) replaces that module's internals without touching the builder, runtime, or wire. Design doc: docs/ragdoll-design.md.
  • Replication rides client-auth semantics: only the machine that owns the entity's physics builds bone bodies; it publishes RagdollPose (motion-class, canonical forEachParsedBone order — no bone names on the wire) plus the entity transform, and every other machine renders the wire pose. The entity's own body/CC handle is suppressed while ragdolled and re-ensures from unchanged components on release.
  • Unragdoll blends: the renderer cross-fades from the last ragdoll pose back to the mixer over blendSeconds (default 0.25 s, smoothstep) — the pose snap is the named failure mode and is pinned red-first (ragdoll-pose.test.ts).
  • Rails per the no-lag law: 12 concurrent ragdolls per place (RAGDOLL_PLACE_CAP, teach-rail refusal naming the consent rule — settled ones count; free one deliberately), and settle-freeze default ON (3 s of stillness converts bone bodies to static — corpses keep collision at ~zero solver cost). The physics watchdog (#10952) keeps watching the melt; the cap keeps ragdolls from causing one.
  • Red-first coverage: bone-body creation from a fixture rig, joint hierarchy matches the skeleton (world anchors coincide < 1 mm at birth — the mantle-joint-feet-anchors lesson pinned at creation), hinge/ball derivation, pose-delta velocity seeding, drop-and-settle simulation, settle-freeze, cap refusal, unragdoll blend continuity.
  • First-person / god-mode local-player hide keeps the player's shadow (jacob's 07-25 report; a95dc46ee9): the hide wrote DrawVisibilityOverride { layerMask: 0 }, which excluded the mesh from the shadow pass as a side effect — the body vanished AND its shadow died. The hide now writes SHADOW_ONLY_LAYER_MASK (bit 31 alone): no camera draws the character (skinned-character tick derives camera-hidden from the camera-mask bits), but it keeps casting, so the first-person player still reads as present on the ground. Root contract pinned by six camera hide asserts (#11014).
  • Skills teach the authored-weapon contract for held/aimed/mounted items — the second pole of verts-in-the-bag (#11000, dig 981498cb; jacob's 07-25 "verts in the bag" ruling reached scenery but every held-item example still routed to generated GLBs). camera-first-person.md §Held-Item Viewmodel now builds the viewmodel from a gen script whose first lines declare the frame (-Z muzzle, +Y up, origin at the grip web — identity rotation aims where the camera looks), exports landmarks as constants (export var SIGHT_LINE), computes the ADS anchor by arithmetic instead of a guessed lerp, and authors hands in the weapon's own frame mounted at identity (they cannot drift off the grip). depthTest: false is qualified to flat/simple viewmodels — it erases self-occlusion inside a detailed mesh. custom-geometry.md routes anything the game must aim, grip, or mount to authored geometry (a generated model is a shape with no contract), and api-reference.md's bone-attachment example marks the held-item rotation as a per-rig knob, not a number to copy.
  • Look-pass reshape (post-land review of #10980, pre-release — no shipped script is affected): ctx.target now returns a write-only descriptor (.previous + .size, no sample surface); the readable texture node is what ctx.pass returns. Pass builders record eagerly in declaration order, so a read that precedes its producing write is unrepresentable — the read-before-write detector and its teaching error are deleted. One narrowed rail survives: a rewrite pass sampling its own target's already-returned readable node (WebGPU-illegal read-while-write) teaches .previous.
  • A stale async pipeline rejection from a superseded look build can no longer park the healthy rebuilt look (built-identity guard on both rejection handlers); a pipeline-failed park now frees all target memory; swapping look scripts recreates same-named targets (history starts from black — param-only rebuilds of the same script keep trail continuity); ctx.target with width but no height (or the mirror) is a teaching error instead of a silent 512×1 texture.
  • skills/looks.md gains a radiance-cascades worked example (disjoint annuli gathered from an emissive key, coarse→fine merge), compiled and lane-pinned by the skill-examples tests alongside trail + separable blur.
  • preview_object renders scripted (kind: "scripted") and custom (kind: "custom") geometry through the engine's own realization path (#11015; wisp-gun dig 981498cb §4 — the booth refused the DEFAULT asset form and the AK wisp burned two failed inspection-camera workarounds). realizeScriptedPrimitiveGeometry is extracted as the pure core of the live spawn derive (compile → seeded ctx → geometry() → UV resolve → createBespokeGeometryValue) — the live lane wraps the same function, behavior byte-identical, and a test pins preview-realized signature === engine-derived signature. The booth renders bespoke pieces on the live slot plan with lane-parity materials (vertex color/PBR/emissive selects, double-sided, translucent slots off the caster draws). Script failures skip with reasons naming the script + error (scripted geometry (scripts/gen/ak.js: radius must be positive)), never a mute skip. Deliberate booth deviations, noted in the reply when hit: no ground sampler (ctx.groundY reads flat ground — the fields: "zero" sibling) and ctx.albedo texture runs render base material (live texture-array residency would make the probe non-deterministic).
  • The authored sign spec is now world state (ledger 1783): a replicated component (tome/sign-spec, replicate aoi) replaces the module-global signSpecCache Map in object-api.ts — the only authored property value that lived outside the world. It now rides the wire, join/reset snapshots, and room-state resume, and dies with its entity (the old Map never evicted on destroy and leaked across rooms sharing an isolate). Consequence fixed: a dotted write (setProperty("sign.text.content", …)) on any isolate that hadn't itself run the sign setter — host takeover, headless room host, room-recycle resume, any fresh client — read null, merged onto {}, and silently rebuilt the whole sign from engine defaults (the 2.4×1.2 board / text maxWidth 2.04 fingerprint).
  • A dotted sign write that finds no sign spec to merge into on an entity that already renders text or a primitive now warns loudly (with the recovery: set the full sign once) before proceeding — silent-rebuild-from-defaults is gone; creation-by-dotted-write on bare entities is unchanged.
  • getProperty("sign") now answers truthfully on every realm the entity replicates to (server reads of client-authored signs returned null before — sign == null was never a valid damage detector).
  • replaceSpecWithStarter gains { wipe: true } — the explicit destroy-everything path through the built-work guard (#11018; holodaek read 83b5ebe3: "slet alt tag mig til grasset" had no honest path — the guard's only offered option, { convert: true }, PRESERVES creator work, the opposite of the ask, so Savi hand-rolled a 7-minute demolition through two schema walls). With wipe the young swap runs unchanged: the starter lands exactly (fresh world, nothing preserved, nothing parked), terrain-edit history demolished on the swap's own rail, undo stacks + session camera overrides cleared, lineage stamped, exactly ONE persistable mutation; the result carries wiped: true. The guard still refuses by DEFAULT and the refusal now names BOTH explicit paths (convert = preserve, wipe = destroy); the option docs and refusal both say wipe fires only on the creator's explicit delete-everything ask, in their words. wipe + convert and wipe + dryRun refuse loudly. Convert mode extracted verbatim to convertSpecToStarter; mutation shape untouched (pinned by test).

Engine v5.2.5

Released July 26, 2026

  • Physics got a big quality pass in worlds using the new physics engine — cleaner collisions, fast objects stop tunneling, vehicles handle better, and things settle instead of jittering. Busy worlds also run noticeably cheaper.
  • You can now stamp marks onto the world: footprints behind a walking character, skid trails behind a drifting car, splats where things land. Marks stick to the world — never to players walking through them.
  • Characters that floated above the ground (some imported models drew way above their own bodies) now stand on their feet, and clicking them works where they're drawn.
  • Saving an object into one room no longer deletes a same-named object from your other rooms — your building stays built.
  • Stuck rooms fix themselves instead of wedging, and games can opt into resuming exactly where everyone left off after a room recycles.
  • Purchases are now stamped by the server, so scripts can't fake who bought something — and as the owner you can test your own buyables for free again.
  • Scheduled game jobs survive restarts and can call external APIs again — daily events and webhooks keep firing.
  • Nameplates and other HTML labels can pin to moving things without any math, worlds with lots of tile art finish loading for new joiners, and letting go of the mouse mid-move always registers.
  • Press F3 for one unified debug console — everything that used to be scattered across debug panels lives in one place.
  • No visible change. Games with lots of scripts (and Savi's emoji in them) hold roughly 10MB less server memory per world, which means more headroom before a big world hits the per-room memory cap.
  • Scripts can declare how often they need to think — export const updateSchedule = { every: 10 } (or { seconds: 0.5 }) runs update() on that cadence, and near: { tag: "player", radius: 50 } lets far-away objects sleep until someone gets close. The engine skips the skipped ticks entirely — no more hand-rolled tick % N gates — and api.setUpdateSchedule({ every: 1 }) wakes a script to full speed the moment it needs it.
  • Worlds full of decorated, interactable-but-idle objects run measurably cooler: scripts that only react to touches, collisions, or timers no longer pay a per-tick cost at all.
  • If your graphics driver crashes mid-session, you now get a clear "reload the page" prompt instead of a frozen black screen — and Savi is told your screen went dark so she doesn't misread her camera tools as a game bug.
  • When Savi's object previews or scene views fail, the error now says why (for example, a lost graphics device), so she can tell a broken shader from a broken GPU session instantly.
  • No visible change. When a player's graphics device dies, the engine's internal report now names the GPU and its memory pressure at the moment of loss, so device-specific crashes get diagnosed faster.
  • Webhooks and external API calls from custom jobs work again where jobs run on the game server (single-player rooms) — fetch() inside a job script reaches the public internet with sane safety limits, so Discord pings, external leaderboards, and third-party APIs behave the way the docs always said they would. On multiplayer rooms, server-context custom jobs keep running on the host player's device, where fetch is the browser's (CORS applies) — unchanged.
  • Logging a huge value (like baked geometry) from a script no longer stalls the world building megabytes of log text — you get a compact summary like [Float32Array(34992)] instead, and everything ordinary logs exactly as before.
  • Night skies are much cheaper to look at — starfields are now precomputed instead of recalculated for every pixel every frame, so GPU power draw no longer spikes when the camera points at a starry sky. Bright stars still twinkle live, and star brightness no longer secretly depends on your screen resolution.
  • Fixed a progress-wipe loop for solo hosts on a bad connection: if your client got stuck in a way the engine's automatic host re-seat couldn't fix, 5.2.4 kept re-seating you every ~20 seconds — and each re-seat reset the place, wiping your unsaved progress in your own world. The engine now tries the automatic fix exactly once; if the same stuck state comes right back, it stops resetting your world and leaves the place frozen (your local progress intact) until the connection genuinely recovers or another player can host.
  • Fixed lights (and other script-animated values) visibly flickering several times a minute in multiplayer games whose looks are driven by behavior scripts — spec refreshes used to briefly snap script-driven objects back to older values before the scripts re-asserted themselves.
  • 2D characters can now stay perfectly straight while they walk. Engine-driven NPCs turn their body toward where they're going, and top-down sprites used to visibly tilt with that turn — walking villagers looked crooked or sideways. Set upright: true on a sprite (or ask Savi) and the art stays axis-aligned no matter what turns the character; cars, ships, and anything that should visibly turn keep working exactly as before.
  • Games can now opt into UI that holds the same size on every monitor. If your HUD was built on a big screen and looks huge (or tiny) on other displays, ask Savi to normalize it — existing layouts keep working unchanged, they just scale to match every player's screen.
  • Uploaded and generated (CDN) models now always get real collision shapes, even when the collider service can't be reached — the engine builds a collision shape from the model's own visible mesh instead of leaving a permanent invisible 1×1 m placeholder box. This ends the class of bugs where props ghost-rolled forever, players stood on invisible boxes, or objects reacted to hits with the wrong shape whenever a collider download failed.
  • On Mantle-physics voxel worlds, placing a block into your own character (or digging and refilling under yourself) no longer freezes you in place forever — the character now pushes out of solid blocks to the nearest opening, the way it already worked on the default physics engine. If there is genuinely no opening (fully buried), you stay put instead of tunneling through blocks.
  • On Mantle-physics worlds, uploaded/CDN models now collide exactly where they look like they should. Before this, a model's collision shapes floated half the model's height above its visual — balls flew through the bottom of scaled models, and dynamic models tipped and reacted strangely because their mass was centered too high.
  • On Mantle-physics worlds, joints now pivot exactly where you author them. Before this, every joint anchor silently shifted by half the body's height, which made motorized platforms shake objects off and hinges swing around the wrong point.
  • Night-sky fixes: the strange vertical chains of duplicate stars are gone (a hash precision bug), and stars stay crisp points instead of soft blobs when the engine renders below native resolution. Star brightness and layout keep their look; individual star positions reshuffle once.
  • The bubble over objects that are still popping in now says "Loading…" instead of making it look like your world was re-generating its stuff on every visit. Nothing is being re-made — already-created things just take a moment to download, and the label now says so. Only genuinely broken things still say "Couldn't spawn."
  • Your game can now declare player-settings defaults (e.g. reduced flashing on by default) and a perf ceiling in your game's settings — ask Savi to "make my game default to reduced flashing" and it's one edit.
  • Players' accessibility choices always win over game defaults — a player who needs reduced flashing keeps it in every game, including yours.
  • Dev-only conveniences finally work the way you'd expect: a script that checks api.getRoomMode() now gets the right answer everywhere — including the code that runs on each player's own machine, which used to always claim "live" even in your build room. Gate test spawners, debug HUDs, or level skips on it and they work for you AND everyone building with you, then switch themselves off in the posted game — no account checks needed.
  • String lights, bunting, and power cables now hang the way you'd picture them — one graceful draped line through the points you place, bulbs and flags spread along the whole run. No more surprise forest of poles around your plaza! If you do want poles under a line, say so and they'll plant themselves along the run.
  • Characters and machines you build from moving parts (each part moved by your scripts every tick) no longer leave a ghostly motion trail behind them — they now look just as sharp in motion as the built-in animated avatars.
  • Your game's UI now runs in its own sealed sandbox. Everything you could build before still works the same way — buttons, panels, saves, sounds, drawing tools, tilt controls — but a game's UI can no longer reach into or break the Spawn page around it, and a crashing UI can't take the game down with it.
  • When UI code does hit the sandbox wall, you get a clear message in the logs saying exactly what isn't allowed there, instead of a button that silently does nothing.
  • Fixed terrain brushes, path drawing, and object handles sometimes only working for a single click instead of a smooth drag in god mode. Drags now track reliably every time.
  • String lights got a glow-up: small warm bulbs that actually blaze, hanging under lines that sag deeper the longer the crossing — like real festoon lights across a plaza.
  • String lights are properly dense now — a blazing festoon of ~2-3 bulbs per meter instead of a sparse dotted line, and you can tune it with bulbSpacing if you want lanterns instead of fairy lights.

technical notes

  • Mantle physics quality wave (#8579): collision, CCD, contact manifolds, joints, terrain interaction, and vehicle handling all hardened, with a playground exhibit touring the edge cases. Perf: sleeping objects ~97% cheaper, characters ~12% cheaper, the busiest simulation frontier ~52% cheaper; a few awake microbenches pay 10–33% more at p50 but everything stays under 3.9ms absolute. The engine default did NOT flip — places opt in via physicsEngine: "mantle" in their spec.
  • api.decal — stamped marks on surfaces (#9938, #9963): games can stamp footprints, skid marks, and splats onto anything in the world. Round 2 fixes from the first walk: a signed receiver hemisphere keeps stamps off characters passing through them, and a 2:1 incidence bound rejects grazing-angle stamps cleanly instead of smearing them into streaks.
  • Reprocessed skinned models grounded (#9484): bind correction now cancels the full wrapper residual, so re-baked character models that drew meters above their own physics (one spider hovered ~30m up) stand where their body is — bounds, grounding, and picking all agree with the drawn mesh.
  • Build/edit safety: place-scoped spawn folds (#9956) — saving an object into one room no longer silently deletes a same-named object from every other room; explicit material keys replace stale override echoes (#9793).
  • Room & version integrity: the spec JSON always travels with its DB version (#9976) — restarts can no longer forge "version 1" and wedge rooms; rejected-save storm damping at the host layer (#9955 — applies to all rooms at kernel deploy, regardless of each game's engine pin); stale-epoch resync fires under sparse drains (#9824); reset delivery with a chain age bound (#9821); opt-in exact-state resume when a room recycles (#9901); multiplayer visibleCount heartbeat (#9743); cf-edge is the single room-tier resolver (#9492), killing the split-room class.
  • DO-tier room hardening (#9974): spec-miss failures are loud instead of silent, and an unpublished room latches terminal (room.unpublished) instead of retrying forever.
  • Jobs & economy: spec.jobs authoring door (#9887); cron and lifecycle jobs survive the dispatch gate and room restarts (#9867); rail.purchase actor is relay-asserted — purchases are stamped server-side so scripts can't fake the buyer (#9899); owners and editors can test their own buyables free again (#9855).
  • Feel & rendering: resumable texture-bake slices (#9891) — rich tile-art worlds finish loading textures instead of parking fresh joiners; camera lens matrices exposed for look scripts (#9945); world-anchored HTML UI (#8870) — nameplates and other HTML pin to entities without hand-rolled projection math; releasing the left mouse button mid-movement registers cleanly (#9823).
  • Terrain & tile art: ctx.wang scripted tilesets — the drawtile port with parity fixtures (#9834); face-depth voice knob (#9908); validation + median-pin fast-follow batch (#9842); rail.fieldDab — runtime heightmap edits reach the server (#9985).
  • Script cadence: god-mode recompose reaps stale overrides (#9790).
  • Unified debug console (#9917, #9929, #9939, #9957): F3 opens one mega-console — GPU-passes panel, frame-graph inspector wings, windowing — one keybinding story instead of scattered debug surfaces.
  • Server memory/infra: relay-tier Durable Object (#9786).
  • Errors & ops: a missing UI export is a first-class script outcome instead of a mystery (#9879); game-alert flood damping (#9880); server logs are pure JSON so Datadog parses them again (#9816); exec disposition noise closed — the last unclassified emitters are classified with a detector in CI (#9877, #9972), which ends 5.2.4's three-errors-per-script-run log spam.
  • Telemetry & test rigs (no behavior change): voxel edit-latency path tag + teardown counter (#9973, #9975); cross-client voxel convergence oracle (#9866); netcode-canary battery (#9856). Voxel digging feel is intentionally UNCHANGED this version — only measurement shipped.
  • Behavior-source retention hygiene (plan p-300376ee lever 1; V8 anatomy PR #9777). Four moves against the Garden-class compile lane's 15.8MB of retained source text:
    1. compileFunctionFactory memo keys are now verbatim NUL-joined params + <bodyLength>:<murmur3_128(body)> instead of the full joined codegen input — the ~6.3MB Garden key family (misattributed to "V8 compilation cache" by the first anatomy pass; erratum on #9777) drops to ~90 bytes/entry. Keys stay a pure function of codegen input, so startup-warmed workerd realms keep serving request-time compiles as pure hits (DO-kernel invariant 3; verified on a no-eval-flag workerd: startup-compile then request-time applySpec of the same spec, zero EvalError). Eviction semantics untouched.
    2. The transform pipeline ASCII-escapes transformed source (\uXXXX, surrogate pairs kept as pairs) in string/template/comment/regex content, then materializes the result one-byte (engines OR representation widths through slice/concat without re-scanning). Tagged templates, non-Latin1 code positions, U+2028/29, active-backslash prefixes, and emoji-dense bodies skip verbatim. Spec originals stay byte-faithful; column offsets on emoji lines drift, line numbers never.
    3. The 3,021-char _f32 + Math-shim preamble inlined per compile unit becomes a two-line bridge to globalThis.__tomeNumRt (tome/numeric-runtime.ts — the shim now exists once per realm as real TS, semantics mirrored verbatim and pinned by deterministic-math.test.ts). Same two-line preamble height, so authored stack line numbers are unchanged; ~15% fewer chars parsed per unit compile.
    4. scriptSourceHashCache + extractDependenciesCache (content-keyed) clear on invalidateBehaviorCache/clearBehaviorCache, bounding Savi-edit-session creep. The factoryMemo is deliberately never swept (invariant 3).
  • Measured on the do-sim rig (local workerd, 600 ticks): Garden compile lane 15.8 → 4.8MB, fixed cost before entities 40.9 → 29.9MB, afterTicks live set 88.6 → 76.1MB; memo-key string family 6.7MB → gone; fn-source family 6.86 → 3.01MB; sky-king fixed cost 23.9 → 22.1MB; smoke unchanged. Tick-time histogram unchanged (mean 18.2ms both sides).
  • updateSchedule — declared behavior cadence, gated BEFORE the sandbox crossing (plan p-953ea933, PR 1 of 2): the compiler extracts one data export, export const updateSchedule = { every: N | { seconds }, near: { tag, radius } | [...] }, and tome/behavior-update evaluates it before the crossing harness (api materialization, per-entity RNG derivation, juice/intent wraps, watchdog timing) — a declined tick costs a map get and an integer compare. every phases deterministically per entity (djb2 of the entity id — no mutable counters, identical on every simulating side); near snapshots tagged positions at most once per (tick, tag) and compares squared distances (same-place, physics poses quantized like query reads; positionless/camera-attached bearers never wake anyone). dt = cadence-step (every × tick dt; flag: plan open decision 1). Runtime override: api.setUpdateSchedule(partial | null) — per-key merge over the static export, scoped to the calling script, side-local (never persisted/replicated), cleared on despawn. Multi-script entities: pre-harness gate is the most-permissive union; each update-bearing entry re-gates inside the merged wrapper with its own cadence dt. Malformed schedules salvage (never fault the script) with one compile-lane report. TomeBehaviorUpdateDebugResource gains scheduleSkippedEvery/scheduleSkippedNear.
  • Composed update is presence-gated like onNoise: composeBehaviors no longer synthesizes an unconditional update wrapper, so behavior-update's if (!update) early-out actually fires for hook-only scripts (DEADZONE receipt, ledger 1463: 912 of 1,132 scripted objects — 80.6% — export no update hook and paid the full harness per tick for a no-op).
  • Dead-GPU-instance clients now reach the reload wall instead of zombieing (staging dig 2026-07-19). When a browser's GPU process dies (chronic on software adapters: SwiftShader under headless Chrome), device.lost never fires and every createRenderPipelineAsync rejects with "A valid external Instance reference no longer exists" — previously that message matched no recovery rail, so the client sat with a black canvas forever: pipelines parked one by one, preview_object/view_live_scene failed generically on every call, and neither the player nor Savi was told the device was gone. The instance-dropped class now joins GPU_DEVICE_DEAD_RE, and handlePipelineBuildError feeds dead-class build rejections into the dead-device burst rail — a sustained burst posts the #6710 reload wall plus the renderer-device-lost diagnostic (Savi's getLogs names the device failure and warns her eye may lie).
  • Capture failures name their cause: preview_object ("Preview render pipelines failed to build (…)") and view_live_scene (encode/downsample build errors) now append the underlying pipeline-build rejection reason, bounded to 300 chars and deduped across pipelines (pipelineBuildFailureText). The cause-free form cost a 14-call isolation session to attribute to GPU death.
  • Every device-loss report now carries adapter identity + a GPU-memory snapshot (dig 11400612): LumeAdapterIdentity (description + degraded/integrated verdicts) was previously reported only on degraded/integrated adapter verdicts, and the #1101 gpu-memory ledger was never attached to loss diagnostics — so a 14-day latch-row sweep could decompose losses into 3 signatures but never say WHICH machine class died at WHAT allocation pressure. Both halves already live in-process at the loss site; this is attachment, not new collection. The renderer-device-lost diagnostic's data gains adapter/adapterDegraded/adapterIntegratedHint plus exact gpuBuffers/gpuBufferBytes/gpuTextures/gpuTextureBytes/gpuTexturePeakBytes, snapshotted at post time from every death path (device.lost, dead-device burst, storm ladder, boot watchdog). The [lume] GPU device lost console line appends the same context in one bounded human-readable clause (describeDeviceLossContext). The #6710/#6741 once-per-session latch and reload wall are untouched.
  • Restored fetch() inside custom job scripts on the DO tier (ledger 1542; regressed in #7667 when rooms moved containers → DO): scripted-job isolates load with globalOutbound: null, so the documented webhook pattern (fetch(args.url) in a spec.jobs handler) was structurally dead on the room server. Job scripts now compile with a fetch shim that forwards a bounded request descriptor over the existing JOB_IO forward lane, and the room host executes the egress — mediated, never a blanket unlock (tome/jobs/job-fetch.ts owns the policy; RoomJobIo.fetchExternal is the host side).
  • Host-side policy on every forwarded creator fetch: absolute http/https URLs only; internal-shaped targets refused (localhost/*.local/*.internal, private/link-local/reserved IP literals in v4 and v6 forms, Spawn service hosts, and the room's own kiln origin); redirects followed manually with every hop re-passing the policy; the outbound request carries exactly the headers the job set — platform credentials never attach. Budgets ride the existing job-limits vocabulary: 32 fetches per run, bodies capped at the job wire's 16MB payload ceiling both directions (streamed, cancel past cap), ~10s wallclock per request, with the job's own deadlineMs still racing the whole run. Refusals and blown budgets settle the job through the normal error channel.
  • Tier semantics unchanged elsewhere: builtin jobs (sparks, leaderboard) keep their platform-trusted shell-side lane; Bun-container and device-local (browser) job scripts keep ambient fetch. Multiplayer rooms are also unchanged: the relay-tier server never executes creator JS and custom jobs are not forwardable (job-forward.ts job_not_forwardable), so server-context custom jobs there run on the resident host client's browser — the mediated lane applies only where scripted jobs run on the room server (the DO-isolate tier).
  • ecs.inspect component allowlist + typed-array summary tokens (p-300376ee lever 5, C1): the debug snapshot accepts an additive components?: string[] filter; the headless-room-host 2s poll passes exactly the five component names its buildWorldView reads (WORLD_VIEW_COMPONENT_NAMES), dropping a Garden-class poll from ≥35MB of JSON text (geometry inflating 6.4× through index-keyed stringify, plus the 9.25MB tome/spec value) to KBs. serializeDebugValue summarizes ArrayBufferViews at any depth, so bulk replicate:"never" typed-array holders (geometry/bespoke class) stay cheap in the unfiltered walk too.
  • Object diff signatures are compact content hashes (C2e): TomeObjectDiffSignaturesResource stored the full canonical-JSON string of every object's properties/tags/state — ~5.6MB resident geometry-as-text per realm on baked-heavy worlds. Now murmur3_128(canonical) + ":" + length (~39 chars/field); equality-compare semantics unchanged.
  • Allocation-free spec-value equality (C3a): the duplicated specValuesEqual copies (object-api + interpreter) collapse into src/tome/spec-value-equality.ts; a structural walk verdict-identical to canonical-text comparison replaces the ~2×1MB transient strings a baked-primitive rewrite built per no-op check (9.6ms → 0.1–2.9ms, zero garbage). Exotic values (toJSON, holes, bigint) still take the text path.
  • Runtime-log value budgets (C3b): formatLogArg caps per-arg JSON at 8,192 chars with O(budget) pre-cap work; oversize values render honest summaries, big typed arrays tokenize in place so their small siblings still log.
  • Night-sky GPU cost (jure's thread: looking at the night sky jumped GPU power ~10W→40W): NO per-pixel star hash walk survives in the sky radiance shader. Both sky_starLayer 3×3×3 hash walks (~54 gated cell visits per sky pixel per frame) are gone from the per-pixel path:
    • Dense faint layer → the night equirect bake (sky-night.ts, same dirty gating — its params were already in nightKey). Each star deposits its analytic energy through a normalized truncated-gaussian kernel from the same hash field (sky_starSite, shared WGSL). Energy target per Sol's P1-1: native-σ energy × FAINT_DUST_CALIBRATION (defaults to the old path's 1080p/60°FOV rendering — the old analytic energy was resolution-dependent, ~1.5× at 1080p and 2–5× at wider FOV/lower res vs ≥1440p parity; the bake is resolution-independent with one taste knob). Kernel σ floors at 0.7 bake texel per tier (Sol's P1-2: sub-texel σ made per-star deposit a ±30% grid-phase lottery at the 512 tier; at ≥0.7·texel the lattice sum equals the continuous normalization to ≤0.1%, pinned by a lattice test at 512). Stars-off skies skip the gather via a uniform gate (fable F2).
    • Sparse bright twinkling layer → baked star-field lookups (sky-stars.ts, new): the star SITES (not an image) bake on CPU into an rgba32float record texture (direction/brightness/tint/twinkle-phase per star, capacity 2048, ~1400 at max density) plus an rgba16uint equirect index (256×128, K=4 slots per texel, conservative texel-diagonal footprint rasterization — no coverage holes at texel boundaries; overflow drops the dimmest star, measured drop mass <0.1% at default and <1% at max density). Per pixel: 1 index fetch + up to 4 record pairs + the walk's exact gaussian/twinkle math — footprint σ still tracks the screen pixel and twinkle animates live from the baked phase, so nothing about the look is frozen. Rotation-invariant like the night equirect. Rebake rides the same nightChanged dirty mark + an exact-density guard.
    • Costs stated honestly: the per-pixel night path is now 1 index fetch + ≤4 records + grain (the nightVisibility > 0 uniform gate still zeroes the whole night stack by day, per pixel). The BAKE passes themselves run once per night-authoring change as before, and the night bake now carries the faint gather (125–343 sites/texel; one-time, milliseconds-scale, stars-off skies skip it). f16 range: baked dust peaks sit multiple f16 ulp above quantization at the night floor at every tier (2–20 ulp dimmest→median, scaling with authored starsIntensity).
    • Evidence: CPU reference mirrors both forms (sky-star-bake.test.ts, sky-bright-star-field.test.ts, star-field-reference.ts) — per-star flux parity vs the calibrated target at every tier, lattice-deposit honesty at 512, record contents vs the analytic derivation, index coverage, K-overflow policy, fetch-vs-walk parity at real pixel angles (5e-3 f32-packing tolerance). New scripts/verify-sky-wgsl compiles every generated sky shader on a real WebGPU device (GPU machine required).
  • Place-host wedge self-heal is convergence-bounded (ledger 1532, dump 85d9a1cc): healWedgedHostCycle's sole-candidate leg now spends at most HOST_WEDGE_HEAL_SELF_REASSIGN_LIMIT (= 1) same-client pipeline re-runs per wedge episode. The same client showing the same wedge signature (off Streaming, resetDeliveryFailStreak ≥ threshold, uplink live) at the next confirm window after a full re-run proves the re-run does not converge that wedge — the second identical election is refused and the breaker opens: the sitting host is retained (frozen-but-not-wiping, the pre-#9618 5.2.2 shape) instead of being re-seated at epoch+1 every window. Breaker-open retention is leg-scoped: the retention ceiling's unhosted endgame still bounds the hasStreamed=true legs, while the never-streamed join-window leg holds retained-frozen with no ceiling — deliberate and harm-free (nothing adopted there to wipe, the diagnostic already fired, and a second joiner / socket replacement / departure all exit it; pinned by the join-window-past-ceiling test). The 5.2.4 field failure was exactly that carousel: a solo place host wedged on DO-tier epoch adoption (stamps pinned to a dead epoch, so no confirm could ever retire the fail streak) had his place world-reset — and his host writes wiped at the stale-epoch gate — every window, forever. The DO-tier epoch-adoption root is a separate dig; this bounds the amplifier.
  • Loud once, per episode: breaker-open emits one logger.error("netcode.place_host.wedge_heal_circuit_open", { placeId, clientId, epoch, failStreak, … }) (container stdout → DD) plus one Savi-visible runtime-log error entry, and increments the new wedgeHealBreakerOpens tenure stat. Not re-emitted on subsequently refused ticks.
  • Breaker reset only on genuine evidence-retire edges: the per-place convergence record (PlaceHostTenureState.wedgeHealByPlace — server room state, never replicated, no client-visible tick arithmetic) prunes when the wedged client's fail streak retires (live-epoch confirm or socket replacement — the exact edges #9618 defined) or the connection departs, re-arming one legitimate re-run for a fresh episode; a momentary Streaming flicker without a confirm deliberately does not reset it (on the DO tier, Streaming can flip on a void-accepted send the platform dropped — projection state is not delivery evidence). A viable different Streaming candidate never consults the breaker: real election always moves the place and leaves the record latched to the wedged client's un-retired evidence — the record's lifecycle is evidence-keyed, never seat-keyed.
  • Named residual blindness (ledger 1532 root dig): the wedge signature's evidence (resetDeliveryFailStreak) increments only on the transport throw path. A silently dropped reset on the DO tier (workerd ws.send() returns void = accepted; a lost chunk of an over-cap snapshot drops client-side with no nack) builds no streak, so neither the heal nor this breaker engages on that variant — and no carousel spins there either (no signature ⇒ no heal ⇒ no epoch churn); that freeze belongs to the delivery-truth root (Streaming-on-ingest / post-reset ack), dug separately. When that delivered-signal lands it upgrades the signature's evidence source in place; heal and breaker inherit it unchanged.
  • Spec traffic never stomps what the local client actively simulates and writes (the 5.2.4 farm-dusk DURING-PLAY lighting flicker — round 2 of tiger's zoo verdict, video receipt 2026-07-19; round 1 was #9754's resync→projection-reset coupling): in client-auth multiplayer the simulating client's behavior writes move its live world AND its local doc first — the forwarded batch → server fold → spec push is the same write echoed back a round trip later, or staler (resync answers after a delta rejection, uncovered-writer full pushes). spec-sync's apply diffed that pushed content against the local doc, read "changed", and re-stamped live values backwards: on farm-dusk the dusk-sweep sun's state.warm/night and light snapped to a stale cycle phase, every threshold-reading lamp/window/firefly/rim script answered with a wink-off at its own %4/%5 cadence, and the rig visibly flickered several times a minute (the video's exact pop→crash→staircase luma signature; tome.apply.episode lane tome/spec-sync visiting ~80/111 objects in the field console). The fix extends the execution-authority rule (behavior-update's isClientAuthLocallySimulatedEntity) to the spec-apply lane: forwardSpecMutationsToRoomSink records the ids this client forwards as a simulation authority (local-sim-write-recency.ts, id + tick, every namespace shape), and a non-replace multiplayer apply substitutes the LOCAL doc's defs for rows whose subtree was locally written within the 90-tick horizon — the reconciler sees no change there and live values survive. The tracked TomeSpec head keeps the server's exact bytes (delta folds stay anchored); replace applies (revert, #296) keep their full "reset live state to this spec" contract; foreign edits to objects the local sim is not writing land exactly as before. Masking is ROW-scoped: a foreign edit to a hot row is deferred whole — for the exact paths a script keeps writing it was already futile (the script's next write wins), but the row's other paths wait for the row to cool (path-scoped masking is the named refinement, netcode owner's call). Convergence is guaranteed: a masked apply arms maskedApplyTick, and spec-sync schedules ONE unmasked re-apply of the current revision once that apply's hot window has fully expired (the same same-revision re-apply lane the place-instance key drift exercises) — so the local doc converges to server bytes at quiescence and deferred foreign edits land, even when the masked push was the last one before the room went quiet (#9826 adversarial review's convergence hole).
  • Tests: spec-sync-local-sim-authority.test.ts — red on parent (a stale-echo push re-stamped a locally-written sun light 0.45→1.1); pins live property + script state + child-pool survival through a stale push, foreign edits still landing, horizon expiry re-asserting the doc, and the quiescence leg: a foreign edit inside the FINAL hot window converges to server bytes after expiry with zero further pushes.
  • sprite.upright: true — the 2D spin lock (QA field specimen app 3d656cc0 / dig 604e064c): in 2D places the quad keeps its base pose (upright in 2d-side, flat in 2d-top) and never inherits the entity's rotation as in-plane spin, whatever writes the rotation — NPC steering's yaw-to-travel-heading (npc.turn clamps to min 10°/s, so it can never be zeroed), physics, scripts. Before this, every walking properties.npc character sprite in 2d-top rendered tilted toward its velocity with no lever to stop it; Savi's only recourse was a per-tick counter-write that fights locomotion.
  • One property, one choke point: instanceRotationFor (lume sprite pack, keyed + standalone paths alike) composes the base pose from zero rotation when upright is set — exact math, so upright sprites keep the fixed pins bit-for-bit. Property absent = byte-identical existing behavior (top-down cars/ships keep turning). Sprite raycast picking mirrors the lock (fillPose), so the ray hits the un-spun quad the player sees. Render-only: rotation still drives physics/colliders and children. No effect in 3D places — billboard owns rotation inheritance there.
  • Spec plumbing: ObjectProperties.sprite.upright (types.ts) + DrawSpriteValue.upright (carries through sparse merges like billboard) + SpriteSpecSchema.upright (the STRICT sprite write gate accepts it). Skills: 2d-mode teaches the lock next to the yaw-spins-sprites rule; npc teaches that steering yaws the body and 2D character sprites want upright: true.
  • Tests: upright pins bit-for-bit in both 2D modes on both render paths while the property-less twin keeps the spin contract, live toggle restores spin, 3D no-op (billboard default and "none" unchanged), raycast pick parity in 2d-top, sparse-merge carry, strict-schema round-trip.
  • Opt-in HUD viewport normalization — engine.ui.referenceHeight (papercut pc-4958f5f3, jacob's opt-in ruling 2026-07-18): the game-HUD lane applied zero viewport normalization, so authored px land as-is and a HUD designed on a 4K monitor covers twice the screen fraction on 1080p. When the knob is set (Savi sets it — never a default), the game-target tome UI containers (tome-ui-container + tome-game-mount) get CSS zoom = viewportHeight / referenceHeight (recomputed live on resize), and the worker's window.innerWidth/window.innerHeight mirror reports the same reference space (innerHeight reads exactly referenceHeight), so authored px mean "px at the reference height" on every monitor. One resolver (resolveUiReferenceHeight, ui-globals.ts) feeds both sides: off for unusable values, clamp 240..4320. Game target only — god-mode chrome never scales. Knob unset is pinned byte-identical: no style write, raw mirror dims, unchanged render-message signatures.
  • Rides the ui render message (pre-resolved, like font), participates in the dedup signature so a knob-only patch re-posts, applies live via api.patchEngine({ ui: { referenceHeight } }) with no reload. patchEngine's silent-ignore warn lane and the tome-schemas EngineConfigSchema both learn the key (schema lenient on purpose — engine clamps at read).
  • Tests: unset byte-identical pins (dom-host style surface, mirror dims, signature), zoom math at three viewport sizes + live resize + clear-on-unset, mirror reference-space math at three sizes, resolver clamp band, schema round-trip parity.
  • The collider cook can no longer park a CDN model as a placeholder box forever (zoo-mantle local-dev field bug: all five CDN-model exhibits had empty collider meshes — the ?transform=collider variant resolved against the absolute https://spawnfile.io base, every URL answered a dead 404, the §(g) classifier latched {retryAt: Infinity}, and mantle kept the props demoted kinematic followers for the whole session, hence the barrel ghost-rolling 170 m with no contacts and no sleep). Two legs:
    • Same rail as the renderer. The lightweight collider lane already fetched root-relative /cdn/ through the assetFetch rail; the asset-service lane (service.ts loadModelAsset, which getModelCollider rides) now fetches through assetFetch too instead of ambient fetch — identical in browsers and on the Bun container tier (no route installed ⇒ assetFetch IS fetch, and the container keeps its legitimate SPAWN_API_URL-based absolute resolution for ambient egress), while under the engine-isolate posture (globalOutbound: null) model loads now ride the shell's fetchAsset rail (public-default origins rebase onto the room's kiln, shell-minted identity) instead of failing unconditionally.
    • Local cook from the visual bytes. When both Magic CDN collider lanes settle with a §(g)-terminal-shaped verdict (dead 4xx / empty cook — never the self-healing parked classes, never the rail refusal), fetchMagicCdnCollider now cooks hulls from the VISUAL GLB the renderer already fetches: the lightweight extractor on the variant URL minus the transform param, then the asset service's own model path. To make that possible, gltf-hull-extractor.ts decodes EXT_meshopt_compression bufferViews through the vendored wasm decoder (lume/model/meshopt.ts initMeshoptDecoder, injectable per the MeshoptDecoderLike pattern; refusal preserved only for wasm-less runtimes) — every Magic CDN visual has been meshopt-compressed + KHR_mesh_quantization (normalized int16 positions, dequantization riding nested node scales) since the v7 corpus republish. Result: one quickhull cloud per primitive — coarser than the CDN's multi-hull decomposition (which stays preferred whenever reachable) but a real shape. Terminal-park classification is gated on the same predicate (readTerminalColliderVerdict), so a permanent park now truthfully means BOTH the variant and the local cook are impossible; a collider-cooked-locally runtime log names the degradation direction once per cook.
  • Regression coverage: meshopt+quantized extraction with nested-scale baking red→green pinned in __tests__/gltf-hull-extractor.test.ts (real meshoptimizer encoder → vendored decoder, plus the wasm-less refusal), and the cook rail in systems/__tests__/collider-assets.test.ts — dead-variant/fetchable-visual recovery on the renderer's root-relative URL family (server and client lanes), empty-variant recovery (narrowing ledger 1491 to "empty everywhere"), and the terminal family updated to prove the park only latches when the visual lane is dead/empty too. Verified against the real barrel visual GLB by manual probe (17 729 verts → 0.687 × 0.900 × 0.687 envelope, matching the served collider fixture).
  • Out of scope, noted for follow-up: a ghost-roll stop condition for demoted kinematic followers, and a distinct debug-draw style for locally-cooked (degraded) colliders.
  • Mantle character controller no longer wedges permanently when a live voxel edit closes solid around the capsule (staging field report: "permanently stuck in one place, no movement works" in the mantle voxels place after break/place edits). Two root causes, both fixed:
    • The collide-and-slide verify sweep accepted initial-overlap (toi 0) hits as blockers. Every surface already overlapping at the start of a move iteration is inside the contact-collect radius and therefore already constrained by the velocity-plane solver — including the depenetration escape — but the raw overlap normal of a second overlapped voxel box could oppose the solver's escape direction, scaling the whole displacement to zero every tick, forever. The sweep now excludes initial overlaps (move-shape.ts VERIFY_SWEEP_OPTIONS, the cast API's existing includeInitialOverlap: false); surfaces newly reached along the path still report toi > 0 and block exactly as before.
    • Per-box EPA picks each greedy voxel box's own minimal exit blind to its neighbors; when a real wall opposes that exit the plane system is infeasible and the solver legitimately yields zero displacement. New union-aware voxel unstick in controller.ts (voxelUnstickInto): when a contact is deeper than padding + half the capsule radius against voxel geometry and the capsule overlaps solid cells, the solid bitset is searched for the nearest capsule-sized air pose (current column + 8-neighbor ring, at the current feet height and successive cell tops, bounded 2.5 m climb) and the pose moves toward it at the depenetration speed cap. No air pocket in reach ⇒ no move — a buried capsule never ratchets upward through a solid column (rapier controller escape parity, its pinned behavior). Deterministic and stateless: a pure function of table + geometry state per tick, no new lanes.
  • Observability: characterVoxelUnsticks scratch counter + mantle:count:characterVoxelUnsticks profiler lane.
  • Regression coverage: distilled wedge scenes lifted from the reproducing fuzz (mantle/character/__tests__/embed-wedge.test.ts — sweep wedge, chimney unstick, lateral-pocket unstick, entombed no-ratchet), end-to-end voxel-edit + CC walk suite over the real fast-lane grid/rebuild/payload functions (__tests__/mantle-voxel-edit-cc.integration.test.ts), and a deterministic 3-seed × 6000-tick dig/place fuzz with a transient-motion wedge detector (__tests__/mantle-voxel-edit-cc.fuzz.test.ts).
  • Mantle now applies the collider cook's re-centering translation to CDN model colliders (staging field report: the CDN barrel exhibits — dynamic barrel "made up of a mess of colliders" responding unnaturally to hits; scaled static barrel's bottom not colliding at all). buildColliderMeshFromHulls anchors cooked geometry to minY=0 and records a translation + feetOffset on the cook; rapier applies that translation to every hull/trimesh desc, but mantle's resolveEffectiveMesh dropped the field, so every CDN collider attached with its bottom at the body origin — a full feetOffset above the feet. resolveEffectiveMesh now carries translation, and mapMeshColliders applies it (× the baked-scale ratio) as localPos on every convex-hull piece, the static trimesh branch, and the bounds-fallback box (which also double-counted the offset and used combined scale instead of the ratio).
  • Regression coverage: __tests__/mantle-cdn-model-collider-offset.test.ts — scaled static multi-hull world-AABB reaches the model's true bottom, dynamic compound COM sits at the geometric center, static trimesh and bounds-fallback placements pinned.
  • Mantle joints now interpret authored anchors in the engine's feet-local frame (staging field report: the zoo motor carousel spun at a perfect 30°/s while its rider cubes bounced violently and flew off). ObjectAPI anchors are feet-local — the engine-wide feet pivot — but mantle-runtime births bodies with origin at the collider center and passed anchors through raw, silently reinterpreting every authored mantle joint's anchors center-relative, off by each body's feetOffset. For the translation-locked carousel raft that meant a permanent 0.4 m constraint violation: each substep the revolute point-block injected phantom bias velocity that applyLockFlags zeroed before integration, but the contact solver in between saw the raft surface rocketing — rider contacts position-popped with zero net impulse. Fixed with feetAnchorToOrigin() at the single addJoint site in syncMantleJoints; the misleading "(feet pivot)" frame docs in solver/joints.ts corrected. Raw MantleWorld.addJoint callers (harness, goldens) are unaffected — the conversion lives in the runtime bridge.
  • Regression coverage: zoo-mantle-live.test.ts drives the real authored carousel exhibit + script and asserts world anchors coincide (<1 mm; reads 0.4 m without the fix), steady spin within ±1% of 30°/s, and both riders carried with feet pinned to the raft.
  • Known adjacent gap, deliberately not changed here: the rapier bridge has the identical raw anchor pass-through over center-origin bodies. Rapier is the prod default and existing games may be tuned against current pivots — changing its anchor interpretation is a separate product decision.
  • Night-sky quality package on the star-bake branch (jure's "combine that with this work", #inventors 1784237988):
    • shade_hash11 returns to the u32 PCG hash three-TSL emitted (shade/std.ts; the lume port had swapped in the Hoskins float hash). The float form loses the star lattice's y/z stride steps to f32 ULP at seeds ~10⁶ — adjacent cells return bit-identical hashes, which stamped duplicate stars down whole columns (the 5.2.x night-sky "vertical dot-chains", community thread + screenshot). The star-site WGSL (faint-dust bake) and the CPU bright-star baker now share ONE hash with bit-exact CPU/WGSL agreement: 32-bit integer math both sides, and the JS mirrors round their return through f32 (Math.fround) to match the WGSL's f32(word) conversion — including the ~3×10⁻⁸ saturation tail that returns exactly 1.0 — so the old f64-vs-f32 threshold-drift caveat is dead. Verified: JS mirrors vs an independent BigInt u32 reference in-repo (drift-pin test in sky-radiance.test.ts, which also string-pins the shipped WGSL's PCG pipeline); the WGSL half executed on an L4 compute pass by the #9482 adversarial review — 230,006 seeds, 0 mismatches. Scripted-material hash() is back on three-era parity (range note: [0, 1], reaching exactly 1.0 for the same sliver the three era did). Look risk stated honestly: content authored against the float hash during the 5.2.0–5.2.x window (≈6 days) sees its hash() values change; everything older gets its original values back.
    • Star/grain footprints are sized against the PRESENTED pixel, not the render-res one (sky-store.ts background pass scales the fwidth derivative by viewportSize/canvasSize): under TAAU the scene renders at 0.75×–0.55× and upscales, so render-res sizing birthed stars fat and the resolve kept them fat — half of the community "blurry night sky". Outside TAAU the ratio is 1 (exact no-op). The σ floor is now a named dial, STAR_FOOTPRINT_PIXELS (sky-radiance.ts, 1.4 output pixels), wired through the CPU test mirrors so parity tests track it.
    • New scripts/profile-sky-night: real-GPU cost probe for the sky composite (real LUT computes + real night bake + real star-field bake, saturated throughput + pass timestamps + nvidia-smi sampling). Measured on an L4 at 1920×1080 night: sky fragment work 1.27 ms/frame on 5.2.x master → 0.36 ms on this branch (−72%); sky-occluded floor cost identical 0.066 ms both builds.
  • In-world asset placeholder chip copy tells the truth (ledger 1675): "Spawning {name}…" → "Loading {name}…" for every non-failed load state (formatAutoPlaceholderLabel / deriveFallbackLabel in renderer/components/placeholder-metadata.ts — the label lane of the lume collectPlaceholders spawning-bubble derivation, #6673). The chip claimed generation while the engine was merely downloading an already-baked asset: 48h of Magic CDN traffic shows zero 202s and zero generation jobs — every placard pop-in on a cache-cold load (~130 assets/session; 1h redirect cache) is a plain 200/302 download of a write-once baked asset plus GPU KTX2 transcode. The placard site sees only LoadState, which cannot distinguish a genuine 202-class generation job from a download, so all non-failed states read as loading (a rare generation job reading as loading is the acceptable direction; a download claiming generation was the reported bug). Failed copy unchanged ("Couldn't spawn {name}"). Savi's-eye getUnreadySceneAssets classification and the capture settle gates are untouched. The anti-flash constant is renamed to what it does (PLACEHOLDER_ANTI_FLASH_DELAY_MS); behavior identical.
  • Player settings resolved seam: user-player-settings.ts now exposes the MERGED view (player global ⊕ per-game override ⊕ creator policy) to every engine consumer; transport payloads carry families + legacy flat reduceFlashing (dual-write both directions, wire-compatible with old parents forever).
  • The engine consumes accessibility signals for the first time: reduce-motion (OS-seeded or explicit) governs juice-hud flash peaks/spacing, entity flash and outline coalescing, and camera-shake softening at ONE seam — isReduceFlashingEnabled() reads the resolved view.
  • Sound buses run two-lane (legacy flat + families) with a families-authority latch; per-game sound overrides resolve above player-global.
  • Creator tier: engine.playerSettings (defaults + caps per the family policy — accessibility is player-absolute, never cappable) validated by the contract sanitizer; savi-writable via the existing spec-edit verbs + the player-settings skill.
  • Perf tier vocabulary unified (PERF_TIERS in @spawn/player-settings is the one copy; tier-override plumbing reads the resolved perf family).
  • api.getRoomMode() is now truthful in client-simulated hooks (the #inventors dev-only-flag miss, 2026-07-23). The read's backing fact (TomeRoomModeResource) was only ever mirrored into the SERVER world (room-runtime bindRoomModeResource, from SPAWN_ROOM_MODE), so in every context where gameplay scripts actually simulate under client authority — player behaviors, singleplayer authority worlds, place hosts — the resource was absent and the read fell back to "live" even inside dev rooms. Now the door-minted room mode (readSurfaceRoomMode: cf-edge's templated __SPAWN_CONTEXT__.roomMode — the validated /{engineHash}/{variantId}/{roomMode}/{roomId} path segment it keys the container DO on, the only place edge iframes carry the mode — falling back to the iframe URL ?roomMode= param on kiln's local-kernel and legacy lanes) rides worker.mount (the servedVariantId/publicUrl/relaySpecBootstrap lane) and configureMountedRuntime mirrors it into the mounted world's TomeRoomModeResource. One fact, one mirror per realm: server = env leg, client = mount leg; no second derivation anywhere. The strict reader (readRoomModeFromHref, engine/runtime/relay/relay-spec-fetch.ts) reports absence instead of coining a default — an unknown mode leaves the resource unset so the read keeps its "live" fallback, exactly how modeSegmentStorageKey treats unknown modes (the relay spec-bootstrap claim keeps its own separate join rule — a JOIN needs a concrete mode, unknown-everywhere → "dev" — but is now fed the same resolved surface mode, so an edge-lane LIVE relay room's latest spec fetch targets the live spec instead of the draft).
  • Side effect, more-correct class: the dev-room shared-key FYI (ledger #996) hooks the ObjectAPI job() seam on whatever world the script runs on — with client worlds now knowing their mode, a dev-room CLIENT-simulated storage write records the FYI line too (it was silently skipped exactly where most gameplay scripts run). Same once-per-key-per-world-session dedupe, still informational-only.
  • getRoomMode()'s declared return narrows string → "dev" | "live" (the implementation always guaranteed it), and the JSDoc gains the room-not-viewer fact: the answer is the ROOM's — every collaborator in the build room reads "dev", every player in the posted game reads "live" — so dev-only conveniences gate on it instead of on account ids, and flip off at publish with zero per-person wiring.
  • Savi-side teaching: getRoomMode joins the always-on hot set in the generated tome-api prompt (the updateStorage promotion precedent — the verb lived only in the api-reference/publish skills, and at author time Savi asserted no mode/build flag is script-readable and keyed a creator's dev flag to his account id). Prompt budget +196 chars with a dated ledger entry (cap 15_700 → 15_900); reach-vocab drift guard extended to pin the collaborator/room-not-viewer vocabulary.
  • Tests: client-world twins for the behavior-context read (dev/live/absent), strict-vs-join href reader contracts, and the always-on prompt pins.
  • BREAKING: wire splines hang by default — poles are opt-in (jacob's ruling, #inventors 2026-07-25). stringlight/bannerline/powerline runs render as ONE continuous draped strand through their authored points: per-span droop (explicit wireSag stays absolute meters per span; the default now scales with span length) smoothed through drooped midpoints with a centripetal Catmull-Rom, so the strand crests interior suspension points tangent-smooth instead of meeting them as N independent per-segment sags with hard kinks. Ground-line points hang at the exact wire height the pole layout used (wireHeight ?? poleHeight-derived), so adding poles: true never moves the wire line; snapToTerrain re-measures hang height from the terrain under every point. New SplineSpec.poles?: boolean restores the #10824 grounded pole+wire layout verbatim; poleHeight alone no longer opts stringlight/bannerline into poles. Field specimen: the town-plaza fence-of-poles (stringlight, poleHeight: 5.6, spacing: 1.5 = a grounded pole every 1.5 m around the fountain) re-renders as draped strands with zero spec edits. streetlight/bollard/zipline (pole/anchor products) keep their generated supports.
  • Generated ids change on bare runs: one __spline__cord tube per run (was per-span span_N), with bulb_N/banner_N indexed along the whole arc (was bulb_<span>_<n>). A bare powerline drapes wireCount ?? 1 cables as wire_N.
  • Behavior-moved rigid model instances emit TRUE object motion vectors under TAAU (ledger 1592 — the hand-rolled-rig ghost/smear): static-batch instances and character rigid children rode the camera-only velocity lane (the named TODO(lume-velocity) in lume/camera.ts), so the TAAU history blended them against where they WERE — characters built from model parts animated by per-tick transform writes left a motion smear while skinned GLB avatars stayed sharp. The fix is the skinned path's prev-palette pattern applied to the rigid lanes: ModelInstance records carry a prev-model mat4 lane (MODEL_STATIC_INSTANCE_FLOATS 36 → 52; appended, so every existing offset is unchanged), written from per-visual prev-world retention in the models prepare loop (rotated before each recompose, with the character path's first-frame/teleport zero-motion resets, sharing VELOCITY_TELEPORT_SPEED), and the static velocity shader variant blends it through lume_velocity_pair — exactly as skinned geometry does. Horde (batched) instances and scripted-material displacement remain camera-only (their own TODO(lume-velocity) arcs).
  • BREAKING: Authored Tome UI executes in an opaque-origin sandboxed iframe per UI target (game/creator), not the host page (engine/ui/tome/realm/ — realm-manager, typed broker protocol, frame runtime). Host ambience (parent/top, cookies, host globals, host DOM) is unreachable from authored UI by construction.
  • Documented capabilities are realm-local: dialogs via sandbox allow-modals; sensors/clipboard-write/gamepad/autoplay via the frame allow= delegation; localStorage/sessionStorage via the per-game storage shim (unprefixed keys, existing data resolves); audio hatch twin with policy-mirrored bus gains; window.spawn via the closed sdk.call allowlist; external window.open + ?room= navigation via budgeted nav.* brokers.
  • Boundary failures are loud: teaching errors ride ui.log into getLogs; realm boot failure = one bounded ui.realm fault, world stays playable. Pre-existing fault hooks keep byte-identical strings and throttles.
  • Host window.sendAction/sendAxis/dispatchUIEvent globals, dom-host authored half, event-bridge scope stack, and host lifecycle compile/invoke die with the move; engine chrome de-inlines to delegated handlers.
  • Skills/docs realm-word pass (game-ui, creator-ui, audio, drawn-art, leaderboard, types.ts @tomeapi + regenerated TomeAPI.md/prompt/skill blocks). Full impact table: docs/tome-ui-authored-realm-migration-notes.md.
  • Fixed god-mode drags (brush strokes, spline draws, handle drags) dying the instant they start: a press over the Tome UI authored-realm overlay focused that iframe, the game window fired blur, and the pointer transport's blur handler force-released the primary button — every gesture committed one tick in with a lost hold. handleBlur now ignores a blur whose focus moved into a data-tome-realm frame (its pointer stream forwards back into the same transport handlers); focus leaving for another window/tab still releases all buttons.
  • BREAKING: draped wireSag scales with span length (jacob's drape-taste veto on the whole-run drape's first render, #inventors 2026-07-25: "too few lights. too big lights. weirdly draped?"). On draped runs wireSag is meters of droop per 10 m of span (default 0.8) — absolute-meters droop flattened every long crossing into a bar (0.6 m over a 30 m span reads straight). Pole layouts (poles: true, streetlight, zipline) keep absolute meters per pole-to-pole span since generated pole spans are uniform spacing-sized. Supersedes the 5.2.5 draft's "explicit wireSag stays absolute meters per span" line — if that draft re-folds onto a sha containing this change, fold this note into it.
  • Draped spans hang as true parabolas now (droop 4·sag·t·(1−t), exact at the anchors, sampled ~0.5 m) smoothed through the parabola's knots with a centripetal Catmull-Rom — no rubbery overshoot between anchor and midpoint, tight-but-smooth crests over interior suspension points.
  • Stringlight bulbs are small warm blazing points (r=0.035 globes hung under the cord, warm amber emissive at intensity 5) instead of 15 cm near-white orbs — real festoon look, on both the draped and poles: true layouts.
  • BREAKING: stringlight bulb pitch is engine taste, not spacing (jacob's density call on the plaza render, #inventors 2026-07-26: "there should be like 3-5x more lights i think then good"). Bulbs pitch at 0.4 m along the strand — one constant across the draped and poles: true layouts (the pole lane hardcoded 1.6 m before), overridable per run with the new bulbSpacing field. Bare draped runs used to read spacing as bulb pitch, but field specs authored spacing for the pole-era layout (support pitch — jacob's plaza: spacing 1.5, poleHeight 5.6), and that read is exactly what gutted his 47 m runs to 31 sparse bulbs. His plaza re-renders at 118 bulbs per run (3.8×) with zero spec edits.
  • Bulb caps degrade density, never coverage: a run longer than the bulb cap allows (512 per draped run, 64 per pole span) widens its pitch so bulbs still span the whole arc — a strand dark for its last stretch reads broken; a slightly sparser strand just reads longer.

Engine v5.2.4

Released July 19, 2026

  • Fixed top-down (2d-top) characters colliding ~1m south of where their sprite stands: walking into a wall from the north stopped short, from the south overlapped, and the debug collider circle drew below the character's feet. Colliders now sit exactly at the sprite's feet regardless of the object's rotation.
  • Camera scripts can now say exactly who owns the view: declare orientation: { source: "script" } on a custom camera and the screen shows precisely what the script authors (death cams, cinematics, forced framing — even while reading the mouse), or source: "look" for engine mouse-look at your declared sensitivity with getViewAngles() to read back what's on screen. Existing cameras are completely untouched — nothing changes unless a camera declares.
  • Glowing models are back: materials using glowTexture: true (an emissive tint that glows through the model's own texture) render their glow again instead of a flat solid color — including rigid attachments on rigged characters, which now glow in the same tint as the rest of the model. If you put glowTexture on a model with no texture, on a scripted material or scripted part, or on a part that also wears its own texture, the engine now tells you why nothing glows instead of staying silent.
  • iPhones opening a game they hadn't touched yet could get permanently marked as "slow devices": iOS slows the page's drawing until the first tap, the engine misread that slowdown as graphics overload, cut visual quality to the floor, and remembered the floor for every future visit. It now recognizes browser-limited frame delivery — even the deep kind — keeps the quality you actually earned, and never lets that state write itself into the device's memory.
  • For devices that were already stuck at low quality by the old behavior: recovery is gradual by design — stored quality decays over the 7-day unconfirmed lease and accrued boot cuts unwind one per session, so a previously-affected device climbs back over its next few sessions rather than instantly.
  • Worlds now load in whole scenes instead of objects popping in one at a time.
  • God-mode clicking is more predictable: invisible helper objects no longer steal your click from the visible thing under the cursor, and parts glued to a character's bones no longer half-select.
  • Long trails and heavy effects stop cutting out mid-stream in effect-dense worlds.
  • Path glows and other fading trails now fade all the way out instead of leaving a faint ghost.
  • Games can now upload images at runtime and use them as real assets (player-drawn art, generated posters, photo walls).
  • Objects spawned with a lifetime now expire on schedule even if the player whose behavior spawned them disconnects or refreshes mid-lifetime — no more leftover markers, projectiles, or effects haunting a room until it restarts.
  • Slow, precise camera look — orbiting an object while keeping it centered, fine aim adjustments — no longer stutters against the frame rate, especially on trackpads and high-refresh displays. Fast flicks are untouched: full speed, no added latency.
  • Invisible data-holder objects (like a leaderboard's storage object) now show up in tag searches again — no more "No survivors yet" over a board that actually has scores.
  • Scripts that chain thousands of .add()/.mul() steps onto a ctx.params value in a loop now keep that param live-patchable — changing it updates the material in place instead of silently rebuilding the whole shader every time.
  • Scripts that build very long material or look chains in a loop (thousands of .add()/.mul() steps) no longer crash the material rebuild with a "Maximum call stack" error — they compile and draw like any other material.
  • Single-player save systems now actually save your game: auto-saves, load-on-join, and save-on-exit hooks run inside the world you're playing instead of a stale server copy — no more progress resetting to an old snapshot after you close the game, and no more live sessions getting wiped back to a first-quest state by a reconnect.
  • Single-player saves stop fighting a ghost: auto-save code in your behavior scripts used to also run in a hidden stale copy of your world, which kept overwriting the real save with day-one data. Now only the world you're playing in writes your storage.
  • Walking and moving character sprites no longer ghost or smear under the high-quality upscaler — sprites now tell the renderer exactly how they moved each frame.
  • Fixed getTerrainHeight() (and getTerrainNormal()) sometimes answering from the lower-detail terrain a device happens to be rendering — on stepped-down devices hilltop heights could read several meters low, so scripts placed or "corrected" objects into the ground. Height reads now always answer the true authored terrain, matching where y: { terrain: N } placements actually land, on every device.
  • A physics engine crash inside a game now recovers by itself within a second instead of leaving the game's physics frozen (objects stuck in place, collisions dead) until the player reloads the page.
  • Older worlds get their object sizes, attachment sockets, and model parts filled in way more reliably now — the game figures them out right in the host player's browser using the same access that already renders the models, instead of a server that sometimes couldn't reach them.
  • Sprites' sizes, ground anchors, and collision shapes stay perfectly consistent for everyone in a room, and load a bit faster on return visits.
  • When a physics shape truly can't load (broken or removed model), Savi now gets one clear message about it instead of the engine quietly retrying forever.
  • Grass and decorations no longer grow-shrink-grow repeatedly while a world first loads — the grow-in now plays exactly once per reveal, and far terrain streaming in never re-grows the field around you.
  • Timers set with runInSeconds/runInTicks no longer vanish when an object's simulation briefly moves to another machine (alt-tab takeovers, host changes, reconnect grace) — they hold and fire as soon as the machine that set them is back in charge. Timers set by a player who then leaves still die with their tab: for deadlines that must survive that, use spawn lifetime or a target tick in state.
  • Big built worlds got dramatically faster: the engine's per-tick bookkeeping for object hierarchies, world hashing, and script writes now scales with what CHANGED instead of how big your world is — a 7,000-object world that was spending its entire frame budget on bookkeeping (and freezing ~150ms every time a script wrote something) now pays almost nothing for the same edits.
  • First-person held items are real now: attach a gun, tool, or hands to the camera (feetPosition: { attach: "camera" }) and it rides the view with zero lag — full 3D models and meshes, not just sprites. Add material: { depthTest: false } (models honor it now too) and the held item never clips into walls. Savi knows the whole recipe: aim-down-sights, bob and sway, per-player viewmodels.
  • "Press E" prompts stop haunting you: interact prompts hide when their object is invisible, gone, or already in your hands — no more permanent "Take" prompt floating on the gun you're holding.
  • Material scripts that displace a surface (mountain relief, painted planets) can now read the surface normal while doing it — those looks compile and draw instead of falling back to a bare white shape.
  • When a look transition can't fade (scripted looks snap by design), Savi is now told at the push — with the recipe for doing the fade inside the script — instead of promising you a smooth dissolve that hard-snapped.
  • Saving a script edit no longer freezes the room: the new version boots in the background while the game keeps running, then swaps in an instant.
  • Savi's changes reach your game much more reliably in singleplayer: rooms with no tab open run the change on the server, a hidden tab answers honestly in ~2 seconds instead of stalling Savi's turn for 20, and work a hidden tab can't do reroutes to the server instead of failing.
  • A game that can't finish joining now fails fast and loud instead of holding you on a silent black screen forever.
  • Music and sound generation that hits your hourly budget now waits politely and resumes when the window lifts — and Savi is told exactly which sound is capped — instead of that audio silently dying for the rest of the session.
  • Destructible worlds keep their damage across script saves: geometry your scripts carved or changed at runtime no longer snaps back to pristine while the collision still remembers every crater.
  • When an image or model is blocked by content policy, you and Savi learn immediately that it was rejected (and why) instead of watching a fake "still generating…" countdown for something that was never coming — only a reword can fix it, and now Savi knows that.
  • Way fewer duplicate error alerts while you build: one broken script is one report (re-armed when Savi actually fixes it, not on a timer), and a script hammering a missing object logs once a minute instead of tens of thousands of times.
  • Sprites no longer vanish for the rest of the session after you tab away: stuck texture loads time out and retry, failed GPU work re-tries itself, and returning to the tab revives anything that broke while you were gone.
  • When the physics engine itself crashes, the errors now say so — instead of blaming your game's scripts and sending Savi chasing a bug that doesn't exist. You and Savi both hear about it honestly, and a reload cures it.
  • Flat painted ground is a real look now: terrain with a tint and no texture stops warning forever about a "missing texture" you never wanted in the first place.
  • Multiplayer host handoffs are steadier: a player who never actually finished loading can no longer hold a place's authority while a fully-loaded player waits.
  • Savi learned to carry run state (inventory, dice, boarding passes) THROUGH place transitions the reliable way — seeded on the travel itself — instead of storage relays that can lose it at the boundary.
  • Dense top-down and 2.5D worlds with lots of depth-sorted sprites (ySort) run dramatically faster — hundreds of trees, bushes, and props that used to each cost their own draw now share batches, with pixel-identical layering (soft-alpha cutout: false sprites keep their old per-sprite path, so their layering is unchanged too).
  • ySort on ordinary sprites is now free: give it to everything on the interleaving layer without a performance tax.
  • Fast-paced worlds that spawn lots of physics props (bombs, projectiles, debris) no longer slowly lose their visual effects: entities the room silently dropped used to linger invisibly and eat effect capacity until explosions just stopped rendering — now they clean themselves up within seconds, effects included.
  • A model that genuinely has no collision shape now tells Savi so once, clearly — instead of the engine silently re-asking forever and slowing down every other asset load in your world.
  • Physics objects no longer twitch while rolling after bumps — a rolling ball or tumbling cube keeps its spin through network corrections instead of freezing and jerking.
  • Savi now gets a log when a squash amount is out of range instead of silence.
  • Savi's script edits now reliably wake up in your world — including worlds nobody has open — instead of occasionally sitting inert until a reboot.
  • Saving a script edit can no longer kick everyone out of the room: one save is one seamless background swap, even when several update signals race.
  • Mouse-look games with on-screen HUD panels no longer trap your cursor: click the game world and you're back to looking around, with the panel still on screen. Real menus and dialogs keep the cursor until you close them, like always.
  • Savi now gets an accurate explanation when event handlers set up while spawning an object don't stick — the error tells her exactly where to move them, so she fixes it on the first try instead of being pointed back at what she already did.
  • Panels you've already clicked past no longer flicker the cursor back when their UI re-renders — the engine now recognizes a re-rendered panel by its id even when the re-render rebuilds the element.
  • Night scenes and colored atmospheres finally show the color you authored: a deep navy vignette is deep navy, a 9% haze reads as 9% — dark looks stop washing out into a pale veil.
  • Multiplayer places no longer freeze up permanently when the hosting player's connection quietly breaks one way — the room detects the wedge within seconds and hands hosting forward on its own, instead of needing Savi to restart the room.
  • Equipment and body rigs are one declaration now: give any object tree parent: "player" and EVERY player in the room wears their own copy — swords, boards, flashlights, full composed rigs — with no pooling scripts, no ownership wrangling, and no more "works with one player, vanishes with two."
  • Fixed a loading screen that could stay stuck at "Starting game…" over a game that was actually running — anyone hitting it had to be told their world was fine; now the curtain lifts as soon as the game is drawn and its content is loaded.
  • Fixed a bug where games with a custom camera and no camera script (common in UI/card games) got stuck on "Starting game…" forever. They load normally now.
  • Custom player models set from scripts finally obey your animations: the default avatar's built-in walk/run no longer hijacks a model your script dressed and animated — your idle/walk/run play the moment your script writes them.
  • Savi's singleplayer fixes land even when your tab is closed or hidden: her script runs on the server instead of failing with "no client" — the biggest remaining class of "Savi said she fixed it but nothing happened" in solo worlds.
  • Savi's teaching got truer in the corners: she now knows spawned objects must live in a resident place to replicate, that one building is one place (no more place-per-floor), how to stamp bone-mounted equipment on every player, and to let YOU be the judge of whether a fix worked.
  • Tilemap tiles can animate now: stack frames vertically in a tileset atlas and set tilesetFrames + tilesetFps — shimmering water, flickering torches, whole layers playing in lockstep. Worlds that don't use it render exactly as before.
  • Deleting a state key finally sticks: patchState({ done: undefined }) survives restarts, and the new api.deleteState("cells.3,4") deletes nested keys — no more stringified-doc or tombstone workarounds for "clear".
  • Bulk property writes (batchSetObjectProperties, setPositions) made in persistent scripts now save like single property writes instead of silently reverting on reload.
  • Cursor-followers stop freezing during movement — the pointer ray now always tracks the camera you see. Cursor rings, placement ghosts, and aim markers stay glued to your pointer while you move.
  • When an art or voice budget runs out mid-build, Savi now learns the real time generation resumes — no more silently missing sprites, and no more "back in an hour" promises over a parked daily budget.
  • Terrain with small repeating ground textures stops reading as an obvious grid from above: tight repeats now get the natural per-tile variation they were always meant to have, while larger hand-authored patterns (8m and up) keep their exact look. Cliff sides are unchanged.
  • Passing a long digits-only string to a drawn-art texture script no longer scrambles it into a giant number — encoded pixel payloads arrive exactly as written, no letter-prefix workaround needed.
  • Sound range finally means what it says: give an ambient sound a maxDistance and it fades out smoothly and goes fully silent at that distance, instead of carrying across the whole map. playSound can take a maxDistance too.
  • api.getInputRay(input) now returns exactly the ray through the pixel the player saw — cursor-following objects (rings, placement ghosts, aim markers) no longer lag or land offset from the cursor while the character is moving.
  • Mouse button releases can no longer get lost under heavy pointer movement: a short press during fast mouse motion releases exactly when the player lets go (held actions stop, keyup-pulsed actions fire).
  • Fixed a multiplayer bug where every light in the room could flicker off and back on (about a fifth of a second, sometimes twice in a row) while someone was editing the game. Spec updates that needed a full refresh used to rebuild the player's whole world; now they refresh just the game spec, invisibly.
  • Rays now hit what players see: api.raycast intersects visible sprites by default — paper enemies, sprite tiles, and decor are targetable without adding physics bodies. Billboarded sprites are hittable from every direction, exactly like they're visible from every direction. Pass { includeSprites: false } if a ray should only see physics colliders.
  • Fixed billboard sprite picking testing a quad mirrored below the sprite's feet.
  • Voxel edits no longer flicker back to the old state when the world is busy: placing or clearing blocks next to active automata (flowing water, busy zoo ponds) used to show your edit for a frame, revert it for about a third of a second, then re-apply it. Your edits now stick the first time.
  • Player sprites keep the size your scripts give them: wearing a skin with an explicit sprite.size no longer snaps to the artwork's minted height when the engine's sprite metadata arrives (the "my character grew mid-walk" bug). Skins worn without a size still auto-size from the asset's declared world height — including when a player jumps straight from an explicitly-sized skin into an auto-sized one.

technical notes

  • Rooms move from Containers to Durable Objects (#7667). The room server no longer needs an OS: the five-isolate container topology (shell, sim worker, network worker, job pool, exec worker) is replaced by a host-blind room core behind three seams (netcode drain/send, externally driven runFrame, exec/job executor) with two hosts. RoomDO is the new default host — websockets + hibernation, a write-ahead ledger in DO storage (durability before visibility), a work-gated tick driver, crons on alarms, idle save — driving a Worker Loader engine isolate that compiles creator scripts at isolate startup; script edits swap the isolate make-before-break while sockets stay open. A thin Bun container host remains as the escalation tier for rooms too big for the isolate memory cap. The multiplayer server world now mounts an explicit 50-system manifest (down from 61) — a slim relay handling lifecycle, storage/LLM/auth rails, and fan-out, with NPC/animation/IK/voxel-tick/physics-step systems unmounted by name and pinned by a manifest test. IMPORTANT: this build ships with 0% of room traffic on the DO host — the new path stays dark until the staging pilot completes; rooms keep running exactly as before until the flip.
  • 2d-top capsule feet-offset Y→Z leak (ledger 1421): every feet↔center conversion now derives the feet→center lift from the SOLVER-representable rotation instead of the raw ECS quat. New primitive projectRotationToRuntime (rapier/runtime.ts) — 3D passes the normalized quat through; 2D collapses to the solver's single in-plane angle (yaw about +Y in 2d-top, roll about +Z in 2d-side), the same collapse writeBodyRotation already applies on write. Previously the lift was rotated by the raw quat, so an out-of-plane WorldRotation (a flat-lying sprite pose, e.g. ±90° about X) expressed the character capsule's 0.95m feet→center lift (halfHeight 0.6 + radius 0.35) on world Z — the visible south axis top-down — genuinely displacing the body ~0.95m south of the sprite's boots at body birth (initializeBodyState) and on every external transform sync (syncPhysicsBodyToComponents → resolveTranslationFromComponents), and drawing the F3 collider ring at the same displaced spot via the view-origin fallback (resolveViewOrigin). The renderer's 2D orientation contract (lume quad-orientation) already composes only the in-plane twist of WorldRotation; physics now matches it.
  • resolveTranslationFromComponents takes the runtime dimension as its first parameter and projects internally. Callers updated: rapier sync (static + dynamic/kinematic transform sync), collider rebuild realign, collider-view fallback (place mode as dimension), terrain rescue feet write. The ObjectAPI door is covered too: syncFeetPositionPhysics (feetPosition/position setters, move(), the interpreter's deferred op, NPC per-tick movement) carried two raw-quat conversions of its own — the live-handle path (transient: same-script-phase queries saw the body displaced for part of a tick) and reconcileUnmaterializedFeetWrite (persistent: a feet write in the disposed/respawn window stored the displaced center, rebirth preferred it, and the external-sync dirty loop skips handleless entities so nothing ever repaired it). Both now project; the live path's no-WorldRotation fallback reads the body via readBodyRotation instead of feeding rapier-2d's scalar angle to quat math raw. After this, every feet↔center conversion in the rapier paths derives the lift from the solver-representable rotation (mantle's own conversions are 3D-only by construction — 2D places always resolve to rapier — where the full quat IS solver-representable).
  • getOrCreateHandle's RigidBodyDesc translation now goes through projectWorldVecToRapier like every later write — rapier-2d's setTranslation(x, y) reads y as world Z in 2d-top, and the raw 3-tuple call was handing it the world-Y feet lift (masked until now by initializeBodyState's immediate re-place, but dead wrong on its own).
  • Deterministic repro tests: 2d-top character + one static box — north vs south walk-in stops at mirror-image feet positions (the ledger 1421 discriminator), body plane position equals written feet under external script-owned movement with a flat WorldRotation re-asserted per tick, no birth displacement, the collider-view ring pinned to the sprite's feet under a flat-lying WorldRotation, and ObjectAPI setProperty("feetPosition") pinned on both the live-handle and disposed/respawn-window paths. Mantle untouched (3D-only; 2D places always resolve to rapier).
  • Authored-axes camera contract (ledger 1294 redesign; Slack C09PQJRDF08/1783986300 convergence, jure's spec): custom camera rigs can now DECLARE per-axis view-orientation ownership — camera.orientation: { source: "look" | "script", authoredAxes?: ("yaw" | "pitch")[] } — replacing every inference of authorial intent from what the script reads or writes. One owner per axis, declared at the API boundary. Script-owned axes are adopted from the authored camera state on every ingest and displayed exactly; mouse input never integrates over them (and the pitch-convention rederive is exempt on an authored pitch — the engine doesn't reinterpret a declared value). Look-owned axes keep display-rate pointer integration, with sensitivity now a typed field on custom defs. A script→look handoff continues from the displayed pose and drains pending input so buffered motion can't double-count. Fully script-owned rigs skip the renderer-authoritative live path entirely and present timeline-exact — a rig may read look axes without the renderer hijacking the screen (the pre-#9059 death-cam discard class, now unrepresentable by declaration rather than adjudicated by a divergence gate).
  • UNDECLARED rigs keep exact 5.2.0 semantics, pinned by test: parameters-only steady state on the renderer orientation (config yaw/pitch never adopted after the kind-transition seed, however the numbers move), the mouse-shape classification heuristic unchanged, and the renderer→state angle write-back (writeCustomCameraAnglesBack) still applied. The write-back is now gated OFF for declared rigs: on the new contract nothing mirrors renderer angles into camera state fields — scripts read feedback through getViewAngles(). The declaration dissolves the echo-window/epsilon/headroom/false-adopt machinery of #9059 (already reverted in #9108) instead of re-windowing it.
  • New read verb getViewAngles(): { yaw, pitch } | null in RADIANS on both CameraAPI (canonical view angles — renderer-published for look-integrated axes, authored state angles for script rigs) and ObjectAPI (same three-tier client/server resolution as getCamera(), which stays degrees). quatToYawPitch promoted to the shared math library.
  • The declaration crosses the sim→renderer camera SAB as three flag bits (declared / yaw / pitch — [] and absent are distinct states), with a writer→reader round-trip test pinning the wire; malformed declarations (bad source, any invalid axis entry) degrade WHOLESALE to the legacy path, and tome-schemas mirrors that exact tolerance jiggle-style (a malformed orientation degrades to undeclared at parse, never rejects the spec — the patchCamera/DB-fold rollback class). Script→look handoffs drain axis-selectively: the handed-off axis's buffered motion is discarded, a still-look-owned axis's pending input integrates.
  • Ported the three-era glowTexture material override (albedo-as-emissiveMap) into lume's model lanes — the #7602 three→lume swap never carried it, so every GLB whose glow rode overrides.glowTexture: true lost its texture term (the emissive override fell into the FLAT lane because the GLB declares no emissive map). Registry-level port: glowTextureVariantOf mints a cached material twin whose emissive map IS the base color texture, so the resolved override rides the existing map-multiplied lane (writeEmissiveLanes) and the existing full texture tier — final emissive = emissive(tint) × albedo × emissiveIntensity, zero new WGSL. Covered lanes: static batches, characters (whole + per-part subOverrides, rigid children included), and horde batches. glowTexture was already structural in both batch-signature classes (never uniform-class), so ± glowTexture visuals never share a batch and the emissive binding cannot leak across a signature reuse.
  • A black (unauthored) source emissiveFactor lifts to white in the glow variant, so glowTexture: true alone glows the albedo as-authored — the three-era skinned-visual default, now uniform across lanes.
  • Rigid children of skinned models (a sword on a hand bone) now resolve the emissive TINT into their instance lanes like every other lane writer — the per-character rigid path passed a null override hex (pre-existing), which the glow port made visible as lifted-white × albedo × intensity next to correctly tinted skinned siblings. The fix covers the whole emissive-tint class on rigid children, not just glow.
  • glowTexture routing is one shared chokepoint (glowRoutedMaterial) across the three lane writers. A part that also mounts an override texture (model.materials.<part> texture/pbr) keeps its BASE material: the mount replaces the GLB texture table at bind time and neither mount tier carries an emissive-map slot, so the albedo-as-emissiveMap variant cannot bind — routing onto it would strand the emissive in a lane basecolor mounts never read (darker than not authoring glowTexture at all). The combo warns instead, and the emissive override still renders as a plain glow color.
  • New log-only diagnostic model-glow-texture-ignored (engine-diagnostics allowlist) for every surface that still ignores the key instead of doing so silently: a material with no base color texture (nothing to glow), a scripted material (top-level or per-part subOverrides glowTexture — the script owns shading), a part claimed by model.subScripts (the part's script owns its shading), and a part mounting an override texture (no emissive slot to bind the albedo into). The silent ignore is how this regression went unnoticed.
  • The quality governor's environmental-cap classification is INTERVAL-RELATIVE instead of budget-relative (ledger 1425). On iOS, a never-interacted cross-origin iframe's rendering updates are throttled to ~2Hz (466–660ms walls observed on iPhone 16 / iOS 26.0), and each starved frame drains ~10 sim ticks of catch-up — measured work inflates to ~20ms, which cleared every budget-relative defense: the clean-2× signature never matches 500ms, the throttle-suspect persist gate (#818) refused on busy ≥ 10ms so floor landings persisted and poisoned the device's future sessions, and the interval-capped regime (ledger 1223) refused on its work gate (20.3ms > budget × calm ratio reads "real load, keep cutting"). Three gates now judge work against the arrival interval where no genuine refresh-cadence overload lives: (1) the interval-cap work gate accepts above the calm band once the fused signal passes the deep-cap line (intervalCapDeepFactor = 4× budget), with the existing unexplained factor carrying the work test there — 20ms explaining a 500ms interval is 4% explained; (2) the throttle-suspect regime's near-idle test widens the same way, so catch-up-inflated busy on a ≥deep-line interval withholds the landing write instead of persisting the floor; (3) the frame-budget guard's environmental gate becomes a full interval gate instead of a 40ms-fallback floor, so a held latch's capped cadence stops re-entering the fused signal and re-descending the ladder the restore just gave back. These sessions now route into the 1223 machinery as designed: restore the pre-descent rung, hold, release when arrivals recover. Everything under the deep-cap line keeps its budget-relative refusals (near-budget real load still classifies as load), work ≈ interval slideshows still descend and persist honestly, and the clean-2× signature path is untouched. The pre-interaction throttle itself (early-interaction gate / same-origin serving, docs/mobile.md) is a separate product decision deliberately not taken here.
  • Admission stability — killed the one-object-at-a-time pop-in after load (#9287): restored the renderer's apply-ack (markApplied after frame collect, so staleness machinery stops over-firing), a pending-vs-lost delivery guard, immutable resets, and one governed admission gate with a derived work budget and O(tree) admission. Worlds admit content in coherent chunks instead of trickling.
  • Places restructure (#9306): runtime-minted place instances (enterPlace createIfMissing, ephemeral/session places) now live in a replicated server-owned registry component and expand to effective PlaceDefs at apply time — they are no longer written into the authored game spec document. The authored spec stays authored; the spec revision no longer moves when a runtime place is minted or torn down.
  • God-mode selection fixes: fully invisible (opacity-0) objects rank below visible objects in click resolution — an invisible helper no longer steals the click from the thing you can see (#9452); children attached to model bones refuse selection consistently instead of sometimes selecting and never steering (#9399).
  • Field-feed convergence (#9371): a walked-path fade now reaches exactly 0 instead of parking at a faint residue, and small per-frame write progress below the quantization step is no longer erased — slow fades complete instead of stalling.
  • FX GPU reservations are sized from the authored population bounds (#9393): long stamped trails and other big authored effect populations stop starving mid-emission.
  • The pass-lights fallback tripwire counts completed renders instead of loop iterations (#9376) — slow-booting worlds no longer false-trip into the fallback lighting path.
  • Behavior job storage retries are bounded (#9441): a storage write that keeps losing its compare-and-set race re-reads fresh once and then parks loudly, instead of retrying forever.
  • New API: spawn.assets.uploadImage (#9357) — frame-callable image upload into servable asset storage, so running games can turn player-made images into real assets.
  • New diagnostic: a spec-shape warning when a rotation carries both lookAt and euler (#9387) — euler silently wins, and the engine now says so instead of leaving the conflict invisible.
  • Hidden-tab capture timeouts name the real cause in their error text (#9412).
  • Play-URL instruments: ?tierOverride forces a device tier (#9416) and ?rungPin holds a quality rung for measurement windows (#9429) — both for test rigs and device farms, no product behavior change.
  • Perf rollups carry the viewport's inner dimensions (#9443), making orientation-split performance measurable.
  • Savi teaching refreshed alongside the engine: primitives dress from the CDN by default with material-slot mounting taught (#9430), the flip idiom and lookAt/yaw conflation teaching pinned (#9384), game-UI inline handlers window-qualify document-shadowed globals (#9451), full-page screens taught fit-or-scroll on phones (#9438), and workshop wisps checkpoint after each stage so long runs resume instead of restarting (#9456).
  • Spawn lifetime is now a replicated deadline component (tome/lifetime-deadline, stamped at spawn as spawn tick + lifetime ticks) instead of an in-memory engine timer, and a new tome/lifetime-reap system destroys past-deadline entities from whatever world currently simulates them: the owning client, the place host (including a suspension-reverted envelope, which joins the remainder WITH its deadline), or the server — which also reaps the UNHOSTED place remainder, the warm-room case where nobody simulates (ledger 732). Previously the timer lived in the world that executed the spawn — the owning client's sim under client-auth — so a refresh mid-lifetime stranded the server-replicated entity forever in a warm room. Re-arm-on-load falls out free: the deadline rides reset snapshots, re-adoption, and host migration. Only lifetime-bearing spawns carry the component (zero wire delta for everything else); armEngineCleanupTimer stays for world-local duration cleanups (effect/look/highlight clears); the armLifetime exec deferred-op dies (the component rides the create row through the merge). Two supporting seam fixes so a simulator's reap actually lands canonically: rail.destroy now authorizes the place host over a suspension-stamped session's adopted envelope (mirroring the StateDeltas drain's suspension rule), and the client-auth upload memo seeds reset-adopted simulated entities so their delete rows drain (an adopted entity destroyed without any intermediate write used to despawn only locally).
  • Live-mouse-look de-aliasing (ledger 1320, tucker's counter-rotation stutter video): the pointer look path drained an integer movementX/Y Atomics accumulator once per render frame with no time base — device event cadence beating against frame cadence lumped two events into one frame and zero into the next (slow precise orbit-tracking stutter; trackpads worst, 60Hz events on a 120Hz display alternate double/empty frames). The camera SAB now carries a seventh slot accumulating the producer-clock span the look samples actually cover (per-sample gaps, capped at 50ms so an idle pause reads as a fresh gesture), and the renderer drains through MouseDeltaReader.drainTimeNormalized(frameDtMs): pending motion releases at the velocity it was produced at (frameDt/span per frame) through a displacement-conserving reservoir. Event rate ≥ frame rate ⇒ everything releases immediately (zero added latency for high-rate mice and flicks); added latency is bounded by one producer sample gap, ≤50ms always. drain() keeps take-everything semantics (reservoir included) for every discard/handoff/seed seam, so camera-kind transitions, sim-authority windows, and authored-axis handoffs still leave nothing to double-count. The visibility seam is closed on BOTH sides of the channel: the main thread clears the SAB slots it owns (as before), and the renderer's visibility message — which runs off the message queue even with rAF parked — discards the worker-side reservoir (LiveCamera.discardPendingInput()), so pre-hide motion can never release as one whip on the first resumed frame (adversarial-review receipt: a sparse-cadence 3×300px sweep whipped ~77° of yaw in one frame without it).
  • pointermove look capture is now coalesced-aware: getCoalescedEvents() sub-samples (feature-detected; Safari and empty-list browsers degrade to the parent event, zeroed-sub browser bugs fall back to the parent delta) feed the camera SAB individually with their own timestamps, so the time-normalization sees the device's honest cadence instead of the rAF-aligned dispatch cadence. The ±350px whip clamp applies per sub-sample; the raw ring's MOUSE_MOVE append stays on the parent event — sim-side gesture traffic is byte-identical. Gamepad look passes its rAF poll timestamp (the main-rAF→worker-rAF phase drift is the same beat) and rides the same de-aliasing; touch look passes no timestamp and keeps its exact raw immediate-release behavior — a sample without a time base carries no coverage, and no wallclock is ever read in the channel.
  • Feel-layer only, below both camera contracts: declared and undeclared rigs (#9148 authoredAxes) integrate the same de-aliased drain identically (pinned by test); the engine still never substitutes camera framing, and nothing replicated changes. Deterministic beat-frequency regression tests drive synthetic event/frame cadences with no real timers: lumpy pre-fix drain, smooth post-fix drain, conservation, idle-start immediacy, stall-backlog bounds.
  • Fixed api.query silently dropping positionless entities (tag/state-only data holders spawned with no feetPosition) on every enumeration path (ledger 1414). The tag-index walker now reads a missing WorldFeetPosition as the pinned origin default {0,0,0} (ledger #359's read contract, matching getObject), and the full-scan, tx-overlay (run_script), and spatial paths gained a positionless supplement leg so all paths agree — sensor surfaces (nearest, player scans) read the same origin default. Missing position counts as the origin in radius tests: a query sphere covering the world origin includes data holders, one that can't reach it excludes them. The supplement rides PositionlessTagBearersResource (maintained at the tag-index chokepoints plus the spatial hooks' position edges, drift-pinned against the component-derived truth), so zero-positionless worlds pay ~nothing and origin-covering queries pay O(data holders), not O(all tagged entities).
  • scripted-material.ts's walkGraph (param classification + non-finite slot detection) is now explicit-stack iterative instead of recursive — the one residual #9361's review adopted (ledger 1307's recursion half). Content scripts mint graph DEPTH with flat loops (for (…) n = n.add(…)), so the recursive walk threw RangeError: Maximum call stack size exceeded out of collectReachableNodes, whose catch degraded the build instead of crashing it: every pending auto param forced raw, up to MAX_UNIFORMIZE_ATTEMPTS (4) builder re-runs, and the material lost uniform patchability (every param change cost a full rebuild). Visit order, first-true early exit, fnCall body expansion timing, and the catch-containment semantics around the walk are byte-identical to the recursive form — the walk trades JS stack for heap, nothing else.
  • Every walk over shade node-graph inputs is now explicit-stack iterative instead of recursive (ledger 1307's recursion half): StageEmitter.count, the ref/express emission fold (now ref/openFrame/compose/finishRef), validateFnBody's capture check, resampleAtUv's rebuild, and fused-look's rejectNode classification + rewriteFrameReads rebuild. Content scripts mint graph DEPTH with flat loops (for (…) n = n.add(…) — sky-king's 445 generated materials), so the recursive walks turned working authored graphs into RangeError: Maximum call stack size exceeded thrown out of the scripted-material rebuild (izkimar's "Maximum call stack while rebuilding materials" episodes) and into caught two-hop parks for looks. Emission order, hoisting, resource first-encounter order, and node-id assignment are byte-identical to the recursive emitter — the walks trade JS stack for heap, nothing else. Emit-time failures that remain are deterministic ShadeTypeErrors, which the per-material catch already parks with a diagnostic and a visible Std/PBR fallback.
  • Ledger 1395 (Farm Island dig c39c9027): extended the runscript forwarding recipe (tome/script-forward) to the engine's own rails. New hook.invoke control message (protocol-additive) carries a hook _invocation descriptor — never code; the client resolves the hook from its own compiled spec — sharing the script lane's pending table, ref space, and ScriptResponse reply channel, so ref mismatches between the two lanes are unrepresentable. Verdicts are coded (no_client/timeout/interrupted/mode_changed/rejected/hook_error) so fire sites can tell delivery residuals (skips, logged) from the game's own script failures (reported through the same behavior-error rails as local runs, with per-script LifecycleHookError attribution reconstructed from the wire).
  • Authority-side executor tome/hook-forward-authority (server bundle — glued client-side in singleplayer, no-op on the real server): strictly FIFO, one invocation in flight, so a save-at-disconnect can never start before a restore-at-connect settles (the takeover/self-heal clobber order is structurally dead); entries wait (deadline-bounded) for the compiled spec / player entity boot races and answer honestly (timeout, rejected on revision skew) instead of rotting or guessing. Persistence flows on the existing rails: storage via tome/job-forward (player-scope authorization unchanged), spec mutations via RoomClientOpcode.SpecMutations.
  • Room-runtime fire sites gate per fire on isSingleplayerWorld (mode can flip live): crons forward with per-cron in-flight dedupe (no autosave thundering against a frozen tab); onPlayerConnected forwards fire-and-track (awaiting would deadlock the join the delivery needs); onPlayerDisconnected forwards awaited, skipping fast when the client is gone and at shutdown (the pump — and the forward rail with it — is already stopped); onPlaceStart forwards per place in order; onPlaceShutdown is a documented singleplayer skip at teardown. CronScheduler's context is now runJob(job, cronId) so the runtime owns the execution-context decision.
  • Reattach outbox prune and room reset settle queued hook.invoke frames loudly (interrupted) on the same synthetic-response channel script.exec uses; a networking-mode flip re-routes queued scripts locally (as before) and settles queued hooks mode_changed (the server-context rails run the next fire correctly).
  • Fix round (adversarial review): three edges hardened. (1) Wake-replay fencing — hook.invoke carries the server's enqueue stamp (issuedAtMs), compared entry-to-entry on the server's own clockline (never against the client clock): a frozen tab that receives a minute-spaced backlog in one wake burst executes at most the newest invocation (older entries answer timeout, same-cron duplicates are superseded outright even inside the window), and the resident client queue is hard-capped at 32. (2) One-in-flight is unconditional — a started invocation is never swept at its deadline (the deadline is a reply deadline, not an overlap license), so a save can never start while a slow restore still runs; unstarted entries behind a blocked head still expire honestly. (3) Authority-epoch fencing — an invocation reaching a world whose spec says multiplayer answers mode_changed at receipt (the no-spec boot race still queues), and the runtime worker clears the queue on singleplayer exit, so a quick singleplayer→multiplayer→singleplayer flip can never replay a stale invocation. (4) Continuation fence (the common root under the first three) — queue membership alone cannot fence a STARTED invocation, whose closure retains the world/api until settlement: each started invocation now captures a monotonic generation, bumped on queue clear (reset, singleplayer exit) and when the sweep cancels a started cron superseded by a newer resident fire of the same cron, and validated at every resumption point — the ObjectAPI (first touch after cancellation aborts the resumed continuation), the settle path (a stale verdict is discarded; the server's verdict already exists), and the job callback brackets (the fence's abort is recognized and discarded, never reported as the game's job_callback_error) — so a buffered job response or a released await can no longer resume a dead invocation into the current world. Supersession is the only sweep cancellation: a started lifecycle head is never cancelled, however stale — a merely-slow restore stays a passive block (the FIFO ordering claim above is unconditional), because cancelling it would run a later autosave against un-restored state.
  • Singleplayer mirror job echo-discard (specimen: dump db485cf6, Farm Island — two save-keepers wrote user/<id>/farm-save once a minute: the client authority and a mirror world permanently at spec defaults; last-writer-wins clobbered the real save). In singleplayer the server world still runs behavior scripts (simulation-authority returns true on both realms) and drained their api.job submissions into the room's real pool with real storage credentials — "read-only" was enforced only for spec mutations (spec-mutation-drain's singleplayer discard). tome/job-dispatch now applies the same precedent to the job lane: on a singleplayer server world, a builtin server-rails job submitted by a mirror-run behavior is discarded at drain (debug-visible aggregate log tome.job_dispatch.singleplayer_echo_discarded), because the client authority provably runs the same behavior code and its own submission rides tome/job-forward under the player's user/<self>/ authorization. The discarded request's callback deliberately never fires (the same silence as any skipped-at-dispatch request) — a synthetic verdict would feed the mirror's scripts a result the real job never produced.
  • Exemptions, by provenance: the lifecycle/cron lane (TomeJobRequest.lifecycleSubmitted, stamped at api.job() when LifecycleContextResource is active) is not an echo — hooks and crons execute only on the server world today (ledger 1395; PR #9366 re-homes those invocations to the client authority, whose jobs ride the client-mode dispatch + job-forward rail and never enter the server world's queue, so the two fixes compose); relocated-interaction continuations (this machine holds the hook's only copy); and custom spec jobs, which stay mode-both local by design — the builtin server rails were the write-capable hole.
  • Sprite apex, following #8684's text apex. Motion vectors now ride depth: on TAAU frames the depth-writing cutout sprite class stamps honest per-instance motion vectors (lume_velocity_pair over VS-computed now/prev world poses) instead of leaving the velocity MRT masked — the fix for walking-character TAA ghosting, where the resolve's closest-depth tap reprojected a depth-writing sprite with the background's velocity. Entity motion comes from a new per-instance prev block (instances.ts, 28 → 36 floats) retained by the per-frame pack; camera-dependent billboard bases resolve in the vertex shader against the new frame.prevView uniform, so camera motion stays repack-free and honest. History reseeds (zero object velocity) on first pack, cull gaps, re-homes, dimension flips, and pose-basis draw replacements (billboard/parallax/repeatX/anchor/size-presence — the retained pose was presented under the old basis and differencing across it would fabricate velocity), with ONE shared same-key adopter serving both the draw/sprite and draw/sprite-instances writers. The whole-tile repeatX camera recenter applies to both poses (a presentation re-parameterization, never differenced). Transparent sprites keep the writeMask-0 velocity doctrine.
  • Composes with #9314's restored pixel-inferred 3D cutout default: the walking-character population — a prod sweep found ~2,500 pixel-classed sprites, including 56/59 3D player sprites — rides the depth-writing class this fix lives on, so pixel characters get honest motion vectors with zero authoring. Known residual (2D parity, pre-existing): authored opacity does not enter the cutout discard, so a pixel-classed sprite faded by opacity keeps writing depth (and now velocity).
  • No sprite distance fade — deliberately (evaluated and dropped in review round 3): in 3D the transparent class is the DEFAULT sprite class (~3.9k live sprites across 216 games in the prod sweep), sprites have no core/fringe twin to fade honestly, and camera-attached sprites present at constant pixel size, so text's 3→6 px band would have permanently dimmed real content. Both sprite classes keep authored alpha out to the pre-existing hard distance cull.
  • No creator-facing spec changes; sprite billboarding and 2D sort-band behavior are unchanged.
  • Staging/dev-only debug-dump gesture (jure, 2026-07-16): five consecutive taps within 2s inside the lower-left 80×80px corner of the game view post spawn:debug-dump-gesture to the parent frame; the kiln page hosting the iframe answers by running the same debug-dump capture the studio Debug chip runs and copying the shareable dump link (touch devices have no F2/Copy path into the perf tooling). New module engine/client/debug-dump-gesture.ts — a pure corner-tap tracker (a tap qualifies only when its pointerup lands within 250ms of its pointerdown with ≤10px of travel — the movement stick's spawn region contains this corner, so stick grabs/flicks never advance the streak; out-of-zone or non-tap presses reset the streak; the window prunes stale taps) plus a hostname gate: staging.play.bigspawn.net, *.staging.bigspawn.net legacy aliases, and the localhost family install the listener; prod (play.bigspawn.net) returns null from the installer and the gesture structurally does not exist there.
  • Input honesty: the listeners observe pointerdown/pointerup/pointercancel capture-phase and passive — never preventDefault/stopPropagation — so every touch (including the five taps completing the pattern) still reaches the game.
  • Wired in worker-browser-host beside the F2 inspector shortcut installer, cleaned up at host dispose.
  • One truth for placement (ledger 1428, the towerwood class-kill): creator-facing terrain height/surface reads now answer from the AUTHORITATIVE sampler — sampleReplicatedTerrainHeight (pure generator eval + replicated terrain:height field layers + voxel edits), the same sampler y: { terrain: N } pins resolve through (position-utils) — never from loaded chunk outputs. Flipped surfaces: api.getTerrainHeight, api.getTerrainNormal (object-api.ts), and run_script's bare getTerrainHeight/getTerrain overlay (exec/engine.ts). Previously these defaulted to sampleTerrainHeight, which prefers whatever chunk output is resident at whatever LOD the device streams (alignment.ts sampleLoadedTerrainHeight) — when the quality governor stepped a device's terrain streaming profile down (streaming.ts terrain-reach seam), hilltops shaved ~5.5m in the loaded picture and scripts planted objects at the blurry LOD surface while spec pins stayed put (towerwood, staging app e09e686e).
  • The live sampler stays engine-internal, verified caller-by-caller: chunk rescue + the collider parity probe (terrain-systems-shared.ts), collider gating (collider-gate.ts), server room placeholders (room-placeholder-manager.ts), the god-mode visual overlays (selection-outline.ts, scatter-footprint.ts — drawn onto the rendered mesh), and cameraApi.getTerrainHeight/getTerrainNormal (camera-api.ts — camera scripts relate to the streamed ground on this client; doc comments now say so). NPC steering/nav never used it (nav-grid samples heightFromDefinition directly). sampleTerrainSurface's explicit source: "live" | "replicated" option is unchanged — the flip is at the creator-facing call sites.
  • Consistency dividend: god-mode drag/placement lanes (default-editors, placement-flow, brush session, authoring-api) compute terrain-relative offsets via api.getTerrainHeight and bake y: { terrain: N } pins — offset math and pin resolution now read the same surface, so a drag on a stepped-down client no longer bakes an offset wrong by the LOD delta. The exec overlay lane was already answering replicated by accident (the transaction-overlay proxy's identity misses the output store's world-keyed WeakMap); it is now the explicit contract.
  • Semantics note, stated honestly: scripts that RELIED on reading the render-LOD/loaded picture (if any) now see the true ground — including edit-free voxel places, where getTerrainHeight previously preferred loaded chunk samples; mid-cell heightmap reads, which now eval the generator exactly instead of bilinear-interpolating baked samples; and legacy UNTAGGED height-sculpt journal edits on old heightmap worlds (chunk builds bake them, the replicated lane does not — pins never saw them either, so reads still match pins exactly; they diverge from the rendered/collider ground on those old worlds, pre-existing pin-lane behavior now shared by reads). Red-first coverage in terrain-height-one-truth.test.ts: synthetic coarse-LOD-resident chunk (5.5m shave) — api.getTerrainHeight/getTerrainNormal/run_script answer generator truth, the internal live sampler still sees the loaded picture, and the API read equals what a terrain pin resolves against.
  • Rapier corruption quarantine is now structural instead of flavor-matched (ledger 1442, dig 06a668b1): any exception escaping the wasm step itself (world.step) marks the runtime corrupted unconditionally — a stack-overflow RangeError unwinding through wasm frames poisons wasm-bindgen's borrow flags exactly like a Rust panic, but the old matcher rethrew it, leaving the world poisoned-but-unmarked and every physics-touching system throwing "recursive use of an object … unsafe aliasing" per tick until page reload (prod app 8b8a0b7d "Canyon Strike", two sessions, ~3.5 min and ~28 min of zombie physics).
  • The per-tick rapier funnels that entry-check corrupted but could never SET it — syncWorldFromRapier, syncExternalTransformChanges, cleanupRemovedPhysicsEntities — now quarantine on any escaping throw and return, so a poisoning that first surfaces there self-quiets next tick and ensureRuntimeForPlace rebuilds a fresh world.
  • isRapierCorruptionError additionally recognizes stack-overflow messages ("Maximum call stack size exceeded", Firefox's "too much recursion") for the remaining pattern-gated call sites (character controller, trigger overlap, sensor broadphase, warmup). Over-marking worst case is one cheap world rebuild + ECS resync — the rebuild path was already proven in prod. Both rapier lanes are covered (the runtime is shared across 3d/2d-side/2d-top).
  • BREAKING (server internals): the four server-side asset-metadata fetch rails (appearance/bounds-prefetch, appearance/socket-prefetch, appearance/parts-prefetch, sprite/metadata-hydration server halves) are deleted from the multiplayer server world. Derivation re-homes to the place host's browser (client-bundle systems gated on the live place-host claim; the singleplayer authority keeps local landing), which uploads over the new rail.assetMetadata command — host-gated, budgeted (16/tick, 64KiB/command, ≤64 sockets/parts per model), validated relay-side with the same predicates the fetch rails ran, idempotent (re-sends of landed truth drop silently), reference-checked (only assets a live entity or the spec names). The relay applies through the shared spec writers (tome/asset-metadata-writes) — three-way landing + one TomeSpec revision bump per accepted command.
  • Additive spec vocabulary: assets.metadata[textureId].sprite2d = { version, meta | null } (canonical-texture-id keyed; meta: null is the replicated terminal "no 2d metadata" answer). Sprite size/anchor/collider upgrades are now pure realm-local computations over the replicated entry.
  • The sockets/parts warm/poll split is collapsed: one authenticated host-session fetch both triggers the MagicCDN cook and reads the sidecar (cooking-base refunds preserved: 502/504/429 × 40).
  • Terminal-refusal semantics for the surviving server asset fetcher (physics/collider-assets/server, plan §(g)): a pre-I/O egress-allowlist refusal (classified by the seam's stable message contract, drift-pinned against resolveAssetFetchTarget's real throws) or a dead 4xx (isDeadAssetHttpStatus, read structurally via response.status incl. one cause hop) parks the cook request for the room's lifetime — one runtime log + deduped DM — while 202/5xx/verdict-window classes keep their existing ladders. gltf-hull-extractor non-ok answers now throw GltfFetchHttpError (structural status).
  • Fixed: multi-hop asset I/O chains inside the engine isolate (the collider rail's probe → GLTF → .bin sequence) were canceled by workerd after the first hop settled — colliders silently stalled as placeholder boxes on the DO tier. Chain owners now register their end-to-end promise (adoptAssetIoChain, wired to the floating-I/O registry by the isolate entry); the workerd asset-rail truth test drives the full three-hop chain.
  • The DO relay stops fetching models for metadata: the slim-server manifest's asset-authority group is down to physics/collider-assets/server + tome/authored-clip-fold (which fetches nothing — it is a patchAssets writer).
  • Terrain decoration birth (the 0.25s grow-in, DECOR_TRANSITION_SECONDS) is now once-per-content instead of once-per-GPU-arena, killing the first-load whole-field pulse (jacob's report: "decorations grow and then shrink and then grow a few times"). Three re-arm paths died: (1) entry-arena rebuilds (ensureDecorationEntries on decorationPoolsKey/config-revision/device changes) now carry each layerId:itemIndex's consumed birth — and its last known draw lane — through a DecorationBirthCarry ledger instead of minting birthPending: true, so every first-load lod-pool arrival no longer re-grew the whole field from zero; (2) draw-readiness re-arms birth only on the null → non-null edge of drawReadyKey, never on value changes — a fixed→scripted material compile landing, a velocity-MRT flip, or a texture-lease upgrade keeps placements and grown scale (the texture-adoption re-arm in bindEntryTexture died with it; a FIRST texture arrival is itself the null → non-null edge); (3) a previously-occupied tile-map cell keeps its birth when chunk refinement re-allocates its pool SLOT (tileMapCellBirth no longer compares slot numbers), so streaming-era slot churn stops re-birthing chunk tiles. Genuinely new content still plays the grow-in: never-revealed items mint birthPending, previously-empty tile cells stamp fresh reveals, and content returning after a real drawability gap (or a teardown-shaped clear) re-births. Regression-pinned in decoration-firstload-pulse.test.ts (all four cases red on the pre-fix engine); steady-state quiescence (F15) untouched.
  • Script timers (api.runInSeconds / api.runInTicks) now survive simulation-authority handoffs in the world that armed them (the timer half of ledger #732's lifetime-deadline work). Timer dispatch used to CONSUME a due timer whose owner another world currently simulates (state.timers.delete before the simulator skip), so any authority round-trip — an alt-tab suspension takeover that reverts, a host seat migrating away and back, a disconnect grace reverting an envelope to the server — silently killed every pending timer for good. Dispatch now retains such timers (the new isOwnerSimulatedElsewhere gate: exactly the two simulation-authority clauses of shouldSkipBehaviorEntity, without its liveness/fault clauses, which still consume) and fires them on the arming world's first simulated-here tick at or after due — the same "expired deadline reaps on the first simulated tick after the stamp" semantic as tome/lifetime-reap. A retained timer still dies with its entity, with cancelTimer/script-edit reaping, or with its world: a departed player's timers die with their tab — the callback is a closure, and code never rides the wire. That boundary is now documented on runInSeconds (generated API docs + skill reference): must-survive deadlines belong in spawn lifetime (replicated, #9051) or in state checked from update().
  • Hierarchy solve is O(changes), not O(objects), per tick (#9593, ledger 1463 wall 1): steady-state ticks expand the hook-fed dirty set downward through a cached inverted TomeParent adjacency and visit only that expansion in cached topo order, instead of walking the full sorted entity list to find the dirty few. Zero per-tick allocation; boot and requestHierarchySolveAll passes still walk everything. DEADZONE-shaped bench (7,245 parented entities, 36-entity per-tick dirty set): 1.19ms → 0.058ms per tick — the prod specimen was paying 22–65ms of hierarchy solve EVERY tick, alone ≈ the whole 30Hz budget.
  • Spec hash is per-node merkle with identity memos (#9595, ledger 1463 wall 2): the once-per-writing-tick TomeSpec.hash stamp hashes structurally — leaves from canonical scalar forms, containers from child hashes, memoized in an identity-keyed WeakMap — so hashing a structural-sharing successor costs O(changed path): 162ms → 0.5ms median per writing tick on an 8.4MB spec. The client's hand-synced stringify clone dies (both realms import the ONE shared module), and the two sanctioned in-place live-doc writers invalidate memos path-precisely.
  • Spec mutation fold is copy-on-write with delta-confined signature invalidation (#9596, ledger 1463 wall 4): foldSpecMutations stops structuredClone-ing the whole doc per forwarded mutation batch — every mutation site owns exactly the path it writes through, untouched subtrees keep the base doc's identity, the no-op gate compares structurally with identity short-circuits, and the TomeSpec stamp goes lazy. An object-array-confined write evicts only the touched rows' diff signatures (plus their expansion-derived ids) instead of dropping all 7,263; anything unattributable falls back to the whole-map drop. Bench at DEADZONE scale: fold 45.3ms → 0.23ms, fold lane end-to-end 151.5ms → 2.7ms with zero whole-spec hashes.
  • feetPosition: { attach: "camera" } now resolves in the mesh, primitive, and model render lanes (#9598, ledger 1468 — finishing the documented primitive only sprites honored): attached roots re-present each frame against the live frame camera (post-smoother, zero-lag by construction — script-driven re-posing swims ≥1 tick behind and cannot be fixed script-side), subtrees transport uniformly in the root's camera-local space (bone-attached children included), depth clamps to live near/far, physics anywhere in an attached subtree refuses loudly at the write boundary, and query() filters subtree members exactly like their root.
  • The model render lane honors material: { depthTest: false } (#9607, ledger 1472 — the documented flag every other lane already read): overlay-pool semantics mirrored exactly from primitives (depthCompare always + no depth write, forced transparent class, draws in the post-alpha overlay slot, never casts shadows), across static batches (LOD + instances included), hordes, and characters with per-part subOverrides and live material-write re-resolve. Scripted materials already honored the flag; plain GLB model materials silently ignored it.
  • camera-first-person skill teaches the held-item viewmodel (#9604, widened to GLB models in #9610 once #9607 landed): spawn with feetPosition: { attach: "camera", x, y, depth }, realm: "client" + audience: "local" for per-viewer items, material: { depthTest: false } for the no-clip lane (primitives, mesh parts, and GLB models all honor it), ADS = offset easing + setCamera({ fov }), bob/sway/recoil = per-tick offset math. The section opens with WHY scripts can't do the transport themselves — the fact that retires the whole re-posing class two power creators hit in 3 days.
  • places skill: state that must survive a place transition rides the enterPlace call itself — seed via createIfMissing.objects or pass the instance id you hold — never a storage write you re-read on the other side of the boundary (#9602, the Diceweaver three-patches-in-four-days class).
  • Interact prompts hide when their source is hidden for the viewer (#9608, ledger 1473): a display-honesty gate reads replicated client state — source not drawn (the renderer's own override ?? base visibility semantics), source riding the viewer (local player's TomeParent attachment subtree or a camera-attach subtree), source destroyed — while prompts on items held by OTHER players keep showing (steal/trade designs stay honest). Kills the permanent "Take" prompt floating on your own held gun; the distance gate is untouched.
  • normalWorld is available in the vertex stage of lume scripted-material assembly (#8768, jure's shade-vocab-gaps class from the 5.2.0 mint): vertex graphs read the exact normal the fragment varying is fed (lume_normalMatrix3 cofactor transform on instanced lanes — correct under non-uniform scale — world-premultiplied skinned pose normals, raw attribute on "none"), hoisted ahead of the positionNode block, so displace-along-the-surface-normal compiles instead of parking the material to its white Std/PBR fallback. A normal-less layout parks with the honest missing-attribute error.
  • pushLook of a scripted look carrying fade > 0 or partial strength now teaches loudly (#9594, ledger 1465 honest floor): scripted looks are selected by priority alone (the strength ramp sweeps only built-in recipe contributions), so those options hard-snap — the authority world emits one content-deduped runtime-log warn + DM (scripted-look-fade-strength-ignored) naming the script self-mixing recipe (a ctx.param("fadeMix", 1) swept by the pushing script). Push behavior itself is unchanged; honored shapes stay silent.
  • Script-edit swaps stop freezing the room (#9572, SS-B of the swap-freeze program p-d2720022): the replacement isolate boots IN PARALLEL while the old isolate keeps serving (mid-boot ticks lose nothing — the snapshot hasn't been taken yet), the handoff runs snapshot → restore → inline apply pre-flip, and flip → durable ledger append → reattach is one sync-contiguous block. The measured 331–468ms of per-swap egress silence collapses to the snapshot encode; any failure — boot, restore, apply throw, or a structured apply rejection — leaves the OLD isolate the room, unswapped and coherent.
  • The singleplayer run_script/hook forward lane is honest end to end (plan p-d2720022 incs 1–2): a no-client SP room reroutes run_script into the server world's script queue with full transactional semantics instead of fast-failing (#9562); device visibility becomes its own wire fact beside projection — initial state on the socket attachment, transitions on a new opcode (#9579); forwards carry an ack fence, so a hidden/frozen tab's offer voids in ~2s with a fact verdict + script.revoke instead of a 20s "most likely backgrounded" guess — 743 of 999 weekly run_script failures were that class (#9581); and a voided forward RETARGETS to the server exec lane, so the change executes instead of merely failing fast (#9586).
  • Join-honesty deadline on the DO boot lane (#9580, ledger 1460): an accepted client socket must reach its welcome within a deadline or close loudly — the forever-open zero-byte join hold (the staging UI-only-spec wedge; a boot-ladder await that hangs without rejecting, the one shape no detector covered) becomes a detected, telemetered close instead of a silent black screen. Ships the sweepSockets one-door discipline the sibling DO landings build on.
  • Audio rate-limit honesty covers the payload-fetch face (#9573, pc-152aff3c): a 429 landing on the real asset fetch after the probe passed now parks the clip with the same typed quota verdict as probe 429s — no attempt burned, one scheduled re-attempt when the window lifts — instead of burning to terminal failedClips in seconds; the retry window reads from the 429 body when the Retry-After header is stripped; a windowless 429 takes one bounded 15-min fallback re-attempt, never a tight loop. The audio-quota-parked diagnostic + DM now carry the exact capped URL.
  • Scripted-primitive re-mints merge runtime-diverged params on non-replace applies (#9533, ledger 1454): a script save re-minting a scripted primitive composes new script content × merged params — doc-changed keys win, runtime-diverged keys survive, the mergeRuntimeStateWithSpecDefaults three-way law applied to primitive.params — and both re-mint call sites hand the SAME effective props to appearance and physics, so colliders rebuild from exactly the params the visible mesh was minted from. Replace applies (revert_to_version) still fully reset, matching state's #296 semantics. Kills the carve-ledger class: visually-pristine hills whose collision remembered every crater.
  • Content-policy asset rejections reach Savi (#9528, ledger 1453 — the engine half of #9495): getUnreadySceneAssets() grows a terminal "rejected" status (+ provider category) rendered FIRST and deliberately without an elapsed-time suffix, and the first cached-verdict 400 fires the model-load-failed teaching diagnostic immediately with a terminal park (zero further network) — instead of riding a 5-attempt retry ladder whose eventual generic message gave the wrong advice and whose oversized key was silently dropped for exactly the long-prompt ids content policy rejects.
  • Savi game-alert flood mechanisms (#9523, jacob's "absurd amount of game alerts"): behavior-error DMs re-key on the unit Savi fixes — (hook, scriptRef) at session scale, re-armed by her own spec save, never by the clock or the volatile error message; [client-reported *] teach rails dedupe spec-version-scoped; idle drips coalesce. DM cadence only — the getLogs stream keeps every signal.
  • The missing-target call-ignored LOG path is wall-clock once-then-count (#9529, ledger 1225): the first ignored call per (operation, target id) logs the full teach dump once per room lifetime, identical repeats fold into one compact counter note per 60s, and post-escalation summaries are time-gated per signature — a hot loop that put 35,711 rows into the kernel error lane in 2h now worst-cases ~1 line/min. run_script's exec overlay still returns every warning verbatim.
  • Lume sprite-material honesty (#9506, the Diceweaver tab-away sprite vanish): failed pipeline builds retry bounded (3 attempts per budget, backoff, re-kicked on access) instead of parking permanently; texture loads carry tick-clock deadlines that abort wedged transports and hand the episode to the ordinary retry ladder; and a residency sweep (retryParked + deadline revalidation) runs on visibility hidden→visible and on projection-reset baselines. Terminal parks (tombstones, dead 4xxs) hold — re-probing them would re-open the tombstone-hammering class.
  • Rapier quarantine classifies the whole wasm RuntimeError family and stops blaming scripts (#9502, ledger 1451 — extending #9471's structural quarantine): any live WebAssembly.RuntimeError is corruption structurally (message families kept for cross-realm copies, now case-insensitive), and post-quarantine hook errors reframe to physics-wasm-corruption attribution across the ops rail, behavior-error reporting (one storm = one stable dedupe key = one DM), and the client fault wire — the specimen session rained 9 wasm faults as behavior-hook errors on an innocent script and Savi built a wrong narrative for the creator off exactly that mis-attribution. Quarantine is surfaced to Savi and the player instead of physics silently going dead.
  • Tint-without-albedo is the authored flat-color state for heightmap terrain materials (#9481, ledger 1446): albedo is optional when tint is present — buildMaterialPack emits the internal color layer as a first-class authored state instead of a warned fallback demotion, patchTerrain/schemas accept tint-only (albedo: null converts), deleting an inert albedo rides the shading-only fast path (zero visual change, no rebuild), and the atlas warning keeps firing only where a DECLARED albedo can't pack — a real defect signal. Kills the eternal-warning loop on intentionally flat painted ground (~92k-token hidden Savi turns).
  • Place-host assignment gains adoption-confirmed tenure (#9394, p-4248883b): every assignment is provisional until the host proves adoption via a one-shot empty host-stamped delta through the normal outbox; unconfirmed-past-timeout re-elects preferring a different Streaming candidate (a sole candidate holds — no epoch churn on a timer), a shared expiry/re-seat cooldown prevents ping-pong with the 1257 forced-re-seat rail, and revocation beats expiry by rule. A join-window host that never received one snapshot can no longer hold an occupied place while a fully-Streaming joiner waits.
  • DO-tier ops hardening from the 07-17 staging storm: wedge recovery gets a poison ladder — 3 consecutive terminal escalations on unchanged spec content identity inside 15 minutes parks the room cold instead of boot-looping the same poisoned spec forever (park-without-abort: workerd's output gate provably discards same-task writes, so the strike lands durably and disposal is in-process) (#9574); POST /rooms/{id}/reset is the DO-tier operator kill switch, returning a poisoned room to never-booted cold with a structural reset latch so mid-flight continuations can't re-poison it (#9577); and the shell memoizes asset-fetch allowlist refusals to one loud log row per (room, URL) with byte-identical rethrows, so frozen old-pin retry ladders stop flooding the error lane (#9560).
  • Internal/observability: per-pass GPU timestamps ride the perf rollup + farm collection (#9597); SS-A swap-freeze phase split + tick-liveness instrumentation (#9565) and run_script emitter-lane counters (#9563); RoomHostLoopback RPC rejections un-blinded in the fleet logpush stream (#9517); MP visibility-aware host demotion pinned e2e (#9561); the do-sim bench's staging-floor leg tears down its room (#9576); workerd-exec settle flake fixed with signal-bound waits (#9519); an ECS delta recorder in the F3 ECS tab (#6918); DeployDefaultWorldStaging targets the static RoomDO host flavor (#9613) and stage.yml deploys the default-world-staging worker on master pushes (#9590).
  • Depth-sorted sprites stay instanced (plan p-ce2385b6). sprite.ySort no longer nulls the batch key for depth-writing sprites (sprite-logic.ts getSpriteBatchKey) — every ySort sprite used to become a STANDALONE batch (own storage buffer, per-frame CPU sync, full pack), and dense top-down games stacked hundreds (overgrowth: ~371 single-instance batches/frame → 9.74 ms lume/render CPU on an L4 rig; prod F2 showed the same shape). ySort sprites now share their texture|blend|layer|order|depthMode|lit|filter batch: the per-frame full pack orders a 2D batch's instances by the standalone era's exact rank (computeSpriteSortKey, stable sort, view-depth tie-break — equal-y never flickers), and each instance carries its own sort band depth in a former pad float (instances.ts sortDepth, flagsB.z), so the vertex shader's clip-Z override is per-instance and the depth test keeps interleaving depth-sorted sprites ACROSS batch boundaries exactly like the standalone era. Cross-batch draw order stays the key's layer/order band (ySort only ever ordered within its layer/order neighborhood — zQuant clamps inside half an order stride). 3D never rank-sorts (zQuant is 0 in 3d): billboards keep physical depth and the existing back-to-front painter order; ySort in 3D is now fully inert instead of an accidental unbatcher. Scope: the batched path covers depth-WRITING sprites — cutout, the 2D alpha default and the entire dense-dressing class. A 2D ySort sprite that resolves depthMode "transparent" (authored cutout: false / alphaCutoff: 0, or a light-like blend) writes no depth — its cross-batch order is pure blend order, which only the per-sprite alpha-bucket sort preserves — so it keeps the standalone path (the pre-batching behavior, carved out in getSpriteBatchKey; pinned by the sprites-ysort fixture's soft column + store/key tests). receiveShadow/parallax/repeatX/camera-attach standalones are untouched.
  • Fixture receipt (371 packed sprites, 358 ySort over 12 textures + 13 props, identical instances both arms): 361 batches → 15, 361 alpha draws → 15, 722 steady-state writeBufferCalls/frame → 15. Real-GPU ordering parity pinned by the new sprites-ysort render-harness fixture (2d-top, two textures, opposite pairwise overlap winners — impossible under whole-batch depth; negative control verified to fail) plus sprites/text-2d/emissive-2d/scripted-sprite-churn green on the L4. The A/B ceiling this chases: stripping the 358 standalone sprites measured lume/render 9.74 → 2.74 ms (−72%), draws 154 → 43, writeBufferCalls 288 → 66.
  • The sprite material staging compare froze: packBatch compared the f64 band depth against its Float32Array staging, which never matched, so EVERY 2D sprite batch rewrote its material uniform every frame. The staged value is now Math.frounded; a static 2D batch settles to one instance-buffer write per frame and zero material writes. (The named non-scope follow-up — revision-gated packing so shared buffers stop full-repacking every frame, model-batch.ts:483 precedent — is deliberately NOT in this change.)
  • Never-acked client-auth creates retire instead of leaking forever (#9675, ledger 1493 — jesse's peakaboom fx exhaustion): a client-auth entity CREATE rides the fire-and-forget StateDeltas lane, and a flood-dropped / outbox-pruned / chain-unwound create left a permanent local-only ghost whose spawned fx (windowless looping populations) held fx-arena population rows until the 256-row table packed solid and every new effect refused. The server drain now acks every create row it ADMITS (create.ack control on the reliable outbox); the client arms a 3s grace per published create (cleared by the ack, by the row appearing in room projection, or by projection reset), and an expired-unacked entity despawns through destroyEntityWithHook with destroy-preview semantics — the cascade takes its spawn-owned subtree (the fx it spawned die with it), frees the arena populations, and publishes delete rows so a late-applied create converges to dead instead of forking. One creator-language runtime-log line per retire. A version-skew latch arms the retire judgment only after the room demonstrates create.ack support, so a new client on an old room can never mass-retire healthy creates. Every arena population's backing entity is now room-admitted, local-realm juice, or retired within ~3s — there is no fourth state.
  • The last permanent-absence collider lane with no terminator is closed (#9656, ledger 1491): a collider cook that succeeds (200 OK, valid GLB) but contains zero static geometry — every mesh skinned, or an empty cook — used to ride the flat 5s retry ladder forever (~12 req/min/asset; the live specimen held magic-cdn at ~59 req/min and 429'd the app's other assets for the room's lifetime). EmptyColliderGeometryError is a typed error at the single empty-hulls throw site, classified structurally (instanceof, one cause hop — never message sniffing), latched into the existing terminal machinery: one honest teaching line to getLogs + deduped DM ("this asset has no collider"), zero further fetches for the room's lifetime. Per-requestKey, so a manifest-signature change mints a fresh honest attempt. Fires only at the outer catch after both extractor lanes settle (the #9094 zoo-statics lesson).
  • The correction blend (correction-blend.ts, projective velocity blending for authority handoffs on lease-class dynamic bodies) projected the abandoned trajectory's POSITION ballistically (fromPos + fromVelPerTick·dt) but held its ROTATION as a frozen quaternion nlerped toward the live stream — every seeded blend froze a rolling body's drawn spin, then swept it, so the roll twitched per correction and died at rest (ledger #1481, george's twitching cube). The blend state now carries fromAngVelPerTick, derived from the ring's last snapshot pair exactly like fromVelPerTick (angularVelocityPerTick: shortest-arc delta quaternion → rotation vector per tick — no wire dependency), and the abandoned rotation is projected (q(dt) = exp(ω·dt/2) ⊗ fromRot) before the shortest-arc nlerp. Zero angular velocity reproduces the previous behavior exactly; the module stays pure and mode-independent.
  • squash()'s [0,1] clamp on amount at the ObjectAPI boundary (in place since #4606, verified against current master) was silent — squash(1.4) quietly became squash(1) and neither Savi nor the creator ever learned the input was out of range. Out-of-range amounts now warn through the mutation-warn rail (getLogs-visible), and the boundary behavior is pinned red-first: above 1 squashes fully, negative stays a no-op, in-range passes through untouched.
  • The DO-tier behavior-compile refusal class is dead, end to end (the 07-16→07-18 staging arc): workerd only permits codegen at isolate startup, so scripts entering a live room outside the /update lane could never compile until an isolate swap — and the swap machinery had no reliable trigger and then no safe concurrency. Four merges close it: RoomDO self-pokes its own update lane on script-bearing drains, so an exec-authored setScript converges to a compiling isolate without waiting for a foreign write (#9643); adoption honesty — the scripts-hash is never adopted over compile refusals, mid-hydration specs no longer downgrade the swap to an in-place apply, and a codegen refusal forces a mandatory heal swap, converging in ≤1 swap (#9652, ledger 1488); exec-authored script edits arm the room's own bounded-backoff heal poke directly (#9661); and the heal is observable — a successful apply retracts the error ring's inert-script report instead of leaving a stale refusal row for Savi to chase (#9673).
  • Script-edit swaps have exactly one arbiter (#9689, the 07-18 staging isolate-restart storm): one save used to dispatch the update lane from three independent triggers (kiln fan-out, exec-response self-poke, drain self-poke) into mandatory-swap machinery — overlapping replacement boots tore every connected client (reconnect races, iframe remounts, ~16s world reboots). Now one in-flight swap per room keyed by target scripts-hash (same-hash triggers absorb into the in-flight swap, different targets serialize), both self-poke lanes are check-then-dispatch (already serving the fetched hash ⇒ no dispatch), the blind "unknown live hash ⇒ swap" default is dead (swap only on a KNOWN differing hash; unknown live hashes apply in place and heal on positive refusal evidence), and every pre-flip swap throw logs a script_edit_swap_failed receipt — the old isolate provably keeps serving. Make-before-break preserved: one save, one replacement boot, sockets stay open.
  • Cursor-need UI is now EDGE-triggered instead of level-triggered (pointer-lock-manager.ts): a visible [data-modal] / [data-needs-cursor] element frees the cursor when it APPEARS, exactly as before — but a pass-through canvas click (one that reaches the canvas rather than any UI pointer target) landing OUTSIDE the bounds of every visible cursor-need element acknowledges the visible set and re-engages pointer lock in the same gesture. Acknowledged-but-still-visible elements no longer hold mouse-look hostage; a fresh appearance (new node, hidden→visible again, attribute re-added) is unacknowledged by construction and frees the cursor again.
  • Bounds-aware click gate: a pass-through click INSIDE any visible cursor-need element's rect is swallowed exactly as before (no acknowledge, no relock). This protects the common prod idiom — a fixed inset-0; pointer-events:none wrapper carrying the attribute around an interactive card — wholesale: its bounds are the viewport, so no misclick beside the card can relock over a mandatory-pick menu. Interactive full-backdrop scrims were already safe (their clicks never reach the canvas). Small ambient panels gain the click-to-relock rescue. One narrow corner trades a click: after a user-Esc unlock, a dead-zone click inside a wrapper's bounds is swallowed WITHOUT re-arming the relock gesture, so a panel that then closes without relockPointer() leaves the player one prompted click from lock where prod today relocked silently — accepted so that a click in a modal's screen region can never pre-authorize a later silent relock.
  • data-modal joins AUTOMATIC_CURSOR_UNLOCK_SELECTORS as the taught vocabulary; data-needs-cursor is compat-accepted with identical mechanics plus a one-per-element deprecation nudge ([Tome] console line + cursor-attribute-deprecated engine diagnostic → getLogs, log-only).
  • api.on() hitting the transactional guard inside an onSpawn that is itself running transactionally (the entity was spawned from run_script) now reports a hook-context message — register the handler from update() on its first run (guarded with a once-flag, since api.on() adds a new subscription every call) or spawn the entity from the spec — instead of the body-context "create a script with api.on(...) instead", which pointed authors at exactly what they had already done (papercut pc-21d6a350: the handler registration is permanently lost post-merge because onSpawn is never re-run live-side). failTransactionalGuard accepts an optional hook-context variant; body-context messages and the sticky exec-fault semantics are unchanged.
  • Cursor-need acknowledgment now survives same-id element replacement (pointer-lock-manager.ts): when the 500ms scan finds an acknowledged element gone from the visible set and an unacknowledged visible cursor-need element carries the same non-empty id (first in document order, if the id is duplicated), the acknowledgment transfers to the replacement instead of it reading as a fresh appearance. Savi-authored UIs re-render through a morphdom-style DOM diff that can replace a modal root outright (new Element identity, same id in the markup), which previously re-freed the cursor on every render of an already-clicked-past panel — flicker plus intermittent re-capture on any UI that re-renders each second. The bridge rides the existing absence grace (the same 2-tick eviction window): an aged-out entry bridges nothing, an empty or missing id never bridges, and a fresh never-acknowledged id still frees the cursor exactly as before. Accepted trade, pinned in tests: a genuinely new modal that recycles a just-acknowledged id within the grace window inherits the acknowledgment. Teaching unchanged: a stable id on the modal root remains the recommended authoring pattern.
  • Look author-colors render display-honest (#9091, ledger 1284 — jacob's waystead washout): the look pass mixed author-supplied color targets raw in tonemapped LDR-linear space with the sRGB encode applied after, so every color target displayed ~2× lighter than authored ("deep navy" presented as pastel periwinkle) and mix amounts read wildly non-perceptually on darks (a 9% haze toward cream lifted sRGB-0.10 pixels to 0.33). Authored colors are sRGB display values, so the blends toward them now happen in encoded space (encodedMix/encodedMixCpu, one shape across all six sites: grade haze, tint, both vignette colors, the fused WGSL vocabulary, and the CPU mirrors). A full-strength target displays as exactly the authored color; mix strength reads as display-referred opacity; sub-epsilon amounts are bit-exact passthrough, so looks without haze/tint/vignette render identically. Scalar grade ops (exposure/saturation/contrast/lift-gamma-gain/temperature) stay in linear with the compensated pivot. Merged on the jure/jacob taste verdict — every look-bearing world re-renders correctly.
  • Place-host wedge self-heal (#9618, ledger 1478): the client-auth place-host reset/epoch cycle could wedge — resets failing while heartbeats pass — freezing replication on a stale world copy with the host's place physics never rebuilt (static colliders dead while meshes render); a sole live-uplink host was retained hosted-on-paper forever, and only a manual room restart recovered (the field specimen wedged every 40–60 min). The wedge signature is now observed, not inferred: resetDeliveryFailStreak counts consecutive wire.send_failed truth events, retired only by edges that prove recovery (socket replacement, or a live-table-corroborated host stamp reaching the drain). A sitting host with the signature (streak ≥ 5, uplink live, off Streaming) is re-assigned epoch-forward through the ordinary assignHost rail — the same machinery restart-rooms drives manually, scoped to the wedged place — preferring a different Streaming candidate when one exists, with a sole candidate re-assigning itself and restarting the retention clock. Healthy paths never touch the heal; the #9394 sole-candidate timer-hold stands untouched.
  • BREAKING (multiplayer parent:"player" rows): player body vocabulary — composed spec trees can BE the player (#9646, plan p-f1a81569). Every spec object tree whose root has parent: "player" instantiates once per active player in multiplayer server rooms, parented to that player's entity and owned via the spawn chain (TomeSpawnedBy stamped depth-1 on every part, so the owner's client-auth writes pass the per-row upload gate — zero latency on your own screen). Parts are ordinary AOI-replicated entities (remotes see the rig on the avatar's stream; place transfers ride the existing subtree transfer). The derivation (instantiatePlayerBodyObjects) is a pure function of (spec, live player set) run on both diff sides of applySpec — spec edits converge for every player through the existing create/update/destroy machinery; join/leave stamp and reap per player (suspension is a table stamp, never a leave). A root behavior runs per instance with api.id = that instance's id. The exactly-one-player fallback for spec rows died, and unresolvable player-scoped parents now warn (deduped) instead of silently skipping.
  • The loading-curtain's worker leg (scene.readyHint) derived from LIVE simulation conditions — current-spec-hash equality AND physics-not-paused — both of which can legitimately stay false or oscillate indefinitely over a fully running world, so the curtain could park at "Starting game… 50%" forever while the sim ticked and the renderer presented frames (ledger 1485, dump 336680ea: 3+ prod occurrences 07-17, hard-refresh/incognito/room-restart immune). An oscillating blocked state also reset the ledger-771 blocked-warn's persistence throttle every flip, which is why the wedge was silent. The leg is now a monotonic content latch (createSceneReadyContentLatch): once the authoritative spec has been applied to this mount epoch's world at least once (TomeAppliedSpecHashResource present — only ever written by a successful apply), scene content is ready and stays ready; physics pause and spec churn are simulation facts that happen behind an OPEN curtain, and the gate's renderer leg (completed presented frames, self-healing per 771) still guarantees pixels before the curtain rises. The one remaining blocked state (awaiting first spec apply) has a constant fingerprint, so the 771 blocked-warn now fires reliably for it. shouldEmitSceneReadyHint and hasAppliedCurrentWorkerTomeSpec (the equality predicate) are deleted; the mount-epoch boundaries (worker dispose + mount init) reset both the latch and the hint channel so a reused worker can never mute the hint behind a stale ack.
  • Instrument rider (same defect family, dig receipt): the host's scene-gate flags (rendererFirstFrameDone / workerReadyHint / domReady / sceneShown) now ride the perf rollup at the parent-relay hop (same seam as the loop census), and kiln retains them into debug dumps (sceneGate block + a Scene gate: summary line). A dump minted over a stuck loading screen now names its false flag directly — the ledger-771 console collector (__spawnCollectSceneGateSnapshot) required a live console the reporter never has, and the dig burned 20 minutes re-deriving what this one line states.
  • Fixed a permanent "Starting game…" boot wedge for specs with camera: { kind: "custom" } and no runnable camera behavior (no behavior refs, or refs that fail to compile — e.g. a deleted/renamed camera script). The camera behavior system used to early-return without ever publishing a camera view, so the renderer never built a frame camera, never presented a first frame, and the scene gate's renderer leg held the boot curtain forever over a healthy running world (refresh/respawn immune — deterministic from the spec shape). A scriptless custom camera now runs an empty static behavior: the authored pose publishes every tick (projection sync, pointer-lock intent, and hideLocalPlayer all still apply), boot completes, and a camera script takes over the moment one lands. One session-scoped console.info names the state.
  • setProperty('model') RETIRES the profile-avatar fallback a3dc instead of retargeting it (#9688, ledger 1496): the fallback dresses a bare player template in DrawAnimated3DCharacter at join; when a creator's script then set a custom model plus its own locomotion, the retargeted a3dc's state machine (feature order 10001, engine runThreshold 3.0 m/s) rewrote the script's identically-keyed idle/walk/run mixer channels every tick — the character ran at walk speed and no script fix could ever take effect (Savi shipped a correct fix twice into that wall). The write seam now reads the same fallback provenance predicate the dress sites use (playerTemplateWantsDefaultAvatar, extracted to player-template.ts) — component removed, its engine-authored locomotion channels (A3DC_LOCOMOTION_CHANNEL_KEYS, the frozen contract) removed with it, script channels under other keys survive. Explicit spec-authored animated3DCharacter (true or { modelId }) keeps the retarget; non-player objects unchanged; matches the spec-apply seam, which already retired a3dc when a model was authored without it (engine-never-decides-anims law).
  • The SP run_script forward lane covers the real caller shape (#9692, plan p-d2720022 b1+b2): production Savi/wisp run_script always carries a userId, which kiln maps to a targeted entry — and the no-client server-exec lane (#9562) only served UNTARGETED entries, so a real Savi call against a closed-tab singleplayer room still fast-failed NO_CLIENT (≈244/week), and the fence-void retarget lane (#9586) stayed dark for the backgrounded case (~74% of the failure corpus). (b1) the no-client server-exec lane widens to provably-absent targets — no connection-table row AND nobody mid-join, decided at the forward system's verdict on unsent entries only; a target with ANY row (mid-join, detach grace, disconnect-save, backgrounded-alive) keeps its behavior byte-identically. (b2) the fence-void retarget lane widens to targeted entries — a hidden/frozen tab's voided offer retargets to server exec instead of merely failing fast, with the late-zombie-reply double-execution detector pinned at ≈0. Storage misroute is impossible by construction (no storage verb callable from any exec site; ScriptQueueEntry carries no identity); identity READS degrade honestly ({userId: null, players: 0} + no-session log note). Forward-lane failures now carry structured verdict codes.
  • fx-gpu refusal telemetry names the actually-exhausted resource (#9666, the ledger 1492→1493 dig aid): an arena refusal now says whether the population/segment table (256 rows) or slot space ran out instead of hardcoding the slot ledger (the wild specimen printed "full" beside 278k free slots), and emits a one-line resident census of who holds the table. Eyes only — refused-newest, the never-cull invariant, and both relief tiers are byte-identical.
  • Teaching sweeps into the GENERATION sources, not just skills (#9650, ledger 1489): the spawn() docstring carries the spawn-residency caveat (non-resident spawns can't replicate or persist in multiplayer — seed via definePlace instead), the generated API reference documents the LIVE RaycastOptions shape instead of the dead pre-tombstone type, and the place-seam criterion (contiguous multi-storey interiors stay ONE place) reaches the PlaceMode/PlaceDef docstrings. Skill teaches: place boundary is a seam — stop teaching place-per-floor (#9645); god-mode blueprint pantries bake into the place spec instead of the shape the non-resident rail rejects (#9648); uploaded-models teaches parent:"player" bone equipment stamps per player (#9655); debugging teaches fix claims hand the verdict to the creator (#9663).
  • Internal/infra: headless room-host staging light-up — image build workflow + container block behind ENABLE_ROOM_HOST (#9626); the room-host shell threads the engine identity into its socket URL so cold rooms boot on the right engine hash (#9680, ledger 1467); a staging return-without-refresh probe harness measuring the pin-bump silent-fork leg (#9690); a wang-tileset staple-family drift test (#9658).
  • SP forward verdict follow-up (#9701, kernel half of the #9692 consumer adoption): the fence-void retarget's honesty note is conditioned on the void's socket fact — a dead-socket no_ack retarget no longer overstates "game tab is open but hidden" when the socket is gone.
  • Animated tileset frames (#9726, plan p-96b0a6ef): tilemap tilesets take tilesetFrames (2–16) + tilesetFps (positive; renderer default 12, the sprite lane's KTX2 default) — playback is a uniform V-shift in the tilemap vertex shader (floor(time×fps) % frames) riding the two spare instance pad floats: zero new bindings, and the shift moves tileUv AND tileUvBounds together so cross-row bleed is unrepresentable (the lit-normals tap shares the one shifted UV). Absent fields resolve to neutrals (frames 1, fps 0 ⇒ shift exactly 0.0), so worlds that don't author them render byte-identically (IEEE identity pinned). The fields ride the terrain apply SIGNATURE (a frames-only edit re-applies), the zod twin in tome-schemas (updateTerrain can author them — TERRAIN_PATCH_ALLOWED_KEYS derives from the schema), and the sim→renderer tilemap chunk codec at v5 (frames as u8, fps as f32, 0 = absent on both — absent round-trips as absent), with a field-by-field round-trip def-equality pin and the stacked-atlas contract pinned: shifted frame-0 rect === tilemapTileUvRect of the frame-major concat. Out-of-range values reject (the lane's tilesetColumns precedent), loop-only playback; fps-without-frames is legal and inert.
  • New deleteState spec mutation kind ({ kind: "deleteState", id, paths: string[][] }, pre-split segment paths) with a shared delete law (deleteStateAtPaths in state-merge.ts) folding identically in the kernel spec fold and kiln's transcription-pinned durable route. New ObjectAPI verbs api.deleteState(path | paths) / api.deleteObjectState(id, path | paths) — dotted paths address nested keys; cross-writer deletes ride the existing client-auth unset intents (top-level and nested, no new intent kinds). The recorder now records patchState's top-level undefined deletes as this mutation kind, closing the resurrection bug (pc-b8ee37bd): deleted keys used to be stripped at record time (JSON cannot spell undefined) and reappeared on every reboot.
  • New setProperties spec mutation kind: batchSetObjectProperties and setPositions now record + spec-mirror their component writes (ONE mutation per object per batch, consecutive same-object mutations coalesced at record time) instead of being silently session-only and stripped by the next spec re-apply touching those defs (pc-aa7c14d3 rider).
  • Pointer-ray camera-derived axes bind the canonical presented camera explicitly (#9742): syncCameraDerivedAxes took the first DrawCamera in ECS iteration order (lexicographic by entity id), so any foreign camera entity sorting before camera/main hijacked pointerOrigin*/pointerDir* wholesale — every getInputRay-per-tick follower (cursor rings, placement ghosts, aim markers) froze at a fixed world position while the player moved. Selection is now explicit: the canonical presented camera first, id-sorted iteration only as fallback. Pinned by a deterministic pointer-follow-ring regression harness (WASD-style camera motion + stationary cursor + entity churn + foreign cameras on both sides of the sort order) plus presentation-contract pins: interpolation-disabled entities never receive a synthetic pose override, and interpolated per-tick movers present within the bounded playout delay.
  • Quota-parked sprite cooks tell Savi the budget truth (#9716, dig d1bb62ac): an art-budget park now sends an art-budget-parked DM mirroring the audio-quota-parked shape instead of parking silently. The audio park DM's resume line tells each cap lane's truth (#9709): the reset time is the server-named budget window end (unclamped Retry-After carried as rateLimitResetAtMs), not the clamped 1h re-probe hold — both lanes share one honest "resume loading automatically around HH:MM UTC" shape, killing the daily-cap self-contradiction where Savi promised voices back "for the next hour" over a ~6h park.
  • Skill teaches: sprite-variant economy — generate variants on demand instead of speculative pre-generation batches (#9722); NPC nameplates anchor by attachment instead of bounds-measuring (#9708, the EverSpark specimen).
  • Spec-by-delta replication (#9712, ledger 1509): the game spec replicates as {revision, ops, hash} on the wire — never the full document per revision.
  • RoomDO alarm-rail hydrate-failure ladder + failure-line self-identification (#9710, ledger 1488): frozen rooms converge onto fixed code instead of waking into the same failure forever, with rung guards from review — the cold-reset rung REFUSES while undrained ledger folds exist (never destroys the one non-re-derivable thing in DO storage; loud alarm_ladder_reset_refused receipt, rail re-arms and the next wake retries the drain), and the repoint rung refuses non-module repoints.
  • ws close-code instrumentation (#9711, rows 1433/1463/1509 — instrument-only, zero close-behavior change): one structured room-do.ws_close row per server-side close ({code, reason, initiator, openForMs, sendFailures, connections}); every client socket-death path reports close info through the worker console relay into kiln's recent-errors ring, so debug-dump connection timelines carry the code per close; the client silence-watchdog kill closes with a distinct app code (4435) + reason instead of an anonymous bare close.
  • verify-fx-gpu-coupling pins the pending-texture skip-draw law at pixel level on a real device (#9325, ledger 1376): a declared-but-unresident/failed texture renders NOTHING, never an untextured solid — previously pinned only against stub devices, now against the actual sprite WGSL in both CPU-snapshot direct draws and GPU arena indirect draws.
  • Terrain anti-tiling never fired since the #7602 lume cutover (#9338): the [2,4] anti-tiling strength band expected repeats-per-chunk magnitudes but was fed repeats-per-METER, so terrain never crossed the threshold. Strength now reads an explicit per-meter band — TERRAIN_STOCHASTIC_REPEATS_PER_METER [0.125, 0.25]: full strength at texture period ≤4m, authored look preserved ≥8m — deliberately NOT keyed on pool.chunkWorldSize (production heightmap chunks are 80m; a streaming knob must not restyle worlds). The jitter lattice hashes worldPos.xz × uvScale so it is world-continuous (terrain UVs are chunk-LOCAL meters — a layerUv lattice would re-anchor at every chunk border); taps stay layerUv+offset so strength-0 is bit-exact. Scope: top-projected surfaces only (biplanar cliff sides stay plain). New verify-terrain-jitter L4 device gate: strength-0 seed identity (byte-identical), jitter-fires (dead-ramp tooth), and two-chunk border continuity against a seamless reference.
  • Texture-script param number coercion is gated on exact round-trip (#9733, pc-63fe8aaa): parseTextureScriptParams coerced any finite-Number()-able string, so a long digits-only payload (e.g. a 256-char encoded pixel string) garbled to a float like 7.777e+256 before the script ever saw it — creators worked around it by prefixing payloads with a letter. Coercion now applies iff String(Number(raw)) === raw.trim() (finiteness guard kept): "4"→4, "0.5"→0.5, "-3"→-3 unchanged; long digit payloads, "007", "1e5", "0x1f" stay strings. drawn-art.md's ctx.params line states the round-trip law.
  • Honest maxDistance — reach semantics for knobless spatial audio (#9378, jacob's 2026-07-15 ambient-SFX report, dig 4b487916): the WebAudio inverse distance model never consults maxDistance, so the one attenuation knob Savi actually writes on ambient components was a no-op. Emitters that author maxDistance WITHOUT refDistance/rolloffFactor now derive attenuation: "reach" — prep folds a normalized clamped-inverse curve (1/d near field, exactly 0 at maxDistance) into the voice gain, sends the panner rolloffFactor: 0, and culls the voice at maxDistance. Authoring either raw knob opts the emitter into untouched WebAudio inverse-distance semantics, exactly as before. The mode is decided at the deriver (the last place the author's optionality is visible), carried as attenuation on AudioEmitterValue.
  • playSound/playSoundAt gain a maxDistance option riding the same reach semantics; audio.md's worked ambient example now teaches the complete honest shape.
  • One ray, one author (ledger 1518): the renderer's canonical pointer ray (seqlocked SAB, published every render frame) is now the sole producer of the pointerOrigin*/pointerDir* input axes whenever it exists. syncCameraDerivedAxes reads it at the top of every client step — before input.sampleForTick, so every sampled frame carries it — and its sim-side re-derivation survives only as the no-renderer fallback (headless runtimes and tests register no reader). Before, the derivation paired the CURRENT event's NDC with the PREVIOUS render frame's camera pose (rendererTransform SAB feedback) and overwrote the renderer's ray before each sample, so the fresher self-consistent ray never reached a script: api.getInputRay picks made while running landed 1.5–2.5m opposite the movement direction (hunt 752d941e frame receipts, Lava Sculptor app d51a2105). Completes #9742, which bound the canonical CAMERA; this binds the canonical RAY. The old pointer-ray-axes netIngest system — whose post-sample writes were re-authored at the next step's top before any frame could carry them (dead writes) — is deleted; god-mode picking already reads the ray straight from the SAB (god-mode/systems/hover.ts).
  • Evict-proof button edges (ledger 1519): the raw-event ring writers (SAB + same-thread fallback) coalesce MOUSE_MOVE under pressure — past half capacity, moves merge into one pending accumulator instead of consuming slots, flushing as a single slot once the reader catches up or a non-move event needs ordering. Deltas are additive for the one consumer (input-resolution's per-tick mouseDx/mouseDy), so merged-vs-individual is consumer-identical below and above the threshold — but position spam can no longer wrap the ring and evict button/key EDGES. Before, ~4-events/s DOWN/UP edges shared 512 slots with per-pointermove traffic (which flows exactly while a button is held or the pointer is locked), and the overwrite-oldest overflow could silently drop a MOUSE_BUTTON_UP: mouseButtonsDown stuck held, hold actions kept firing, keyup pulses never arrived (the bloom growing ~1s past LMB release, felt on prod 5.2.3 too). The SAB writer's flush saturates the accumulated delta at the Int32 encode bound (±2³¹/scale ≈ ±214,748px) — a wrap-scale window is stall artifact, not motion — and a merged tail whose motion stops mid-congestion flushes on the next append of any type: late, never lost (pre-coalescing it was evicted permanently). Known residuals, named honestly: gamepad-axis spam (changed-axes × poll rate, needs a >1.4s live-worker stall with an active stick) and wheel-momentum spam (~120/s, needs a ~4s stall) are theoretically flood-capable producers of the same class and are left alone — the receipted producer is MOUSE_MOVE, and the same coalesce shape extends to either if it ever shows up in the field.
  • Tests: red-first pins at the transport layer (DOWN/UP survive an 8× ring-size flood in both writer classes, order preserved, delta conservation, healthy path unchanged, wrap-scale accumulator flushes saturated and sign-correct), end-to-end through input-resolution (a hold action releases and its keyup pulse fires when the release rides a flooded ring), canonical-ray authorship (renderer ray wins over the derivation; never-written channel falls back), and the publish-order pin (each tick's sampled frame carries the ray published before the step). The #9742 pointer-ring-freeze regression suite and camera-derived-axes suite stay green on the fallback path.
  • Multiplayer spec resync is answered with ONE full tome.spec.push to exactly the asking connection — never a projection reset (the 5.2.4 farm-dusk whole-room lighting flicker, thread 1784418124, discriminator 4cb67aa5): spec-by-delta's recovery rail (spec-resync.ts) answered multiplayer gap / hash-mismatch / no-baseline requests by flagging the connection NeedsReset, so recovering a single component value rebased the client's entire world — sweepProjectionForReset despawned+respawned every projected entity, the renderer's lights map emptied (~170 ms fallback-rig flicker across the whole room), and the reset unhosted the place host → re-election → a second reset (the exactly-two-flickers cascade). The singleplayer answer — a targeted full push — was equally correct for multiplayer all along: adoptMultiplayerWorkerFullSpecPush already names resync responses as a replace-class anchor (monotonic revision gate), and a queued full push survives the reattach outbox prune by design. The mode split is deleted; every resync now emits the same targeted full push. This removes the spec-traffic→reset coupling entirely — the reset lane keeps its real callers (reattach, transport drops, wedged senders, place changes) untouched, and spec-delta-journal's "an under-describing revision degrades to full-push behavior" claim is now true in multiplayer too (it used to degrade to a full projection reset).
  • Tests: e2e regression pin (client-auth-spec-resync-e2e.test.ts, adapted from the discriminator's ARM A drive — red on parent behavior): every spec-push delivery class (clean deltas, full pushes, hash-mismatch, revision gap) against a multiplayer client holding 5 authored DrawLight rows — rejection classes must recover the spec head with zero projection resets, zero DrawLight despawns, no light-count dips, and no place-host re-election; plus a reconnect-class NeedsReset phase pinning that the reset lane itself still delivers (snapshot rebuilds the projection, spec head preserved). Unit pin flipped in spec-resync.test.ts: multiplayer now expects the emitted full push and an untouched projectionState. The client-auth e2e harness gains production spec-push wiring (applyWorkerSpecPush on control ingest + registerSpecResyncCommand, registered exactly as room-runtime does).
  • api.raycast hits sprite quads by default (jacob's default-on ruling, 2026-07-18): the visible thing is the hittable thing, no physics body required. billboard: "none" sprites test their authored world-space quad (feetPosition + WorldRotation via the pack's exact instance-quaternion swizzle + sprite.size × WorldScale × GeometryScale at the anchor, neutral atlas registration); billboarded sprites ("yaw"/"full") orient toward the RAY ORIGIN — a billboard faces every viewer, the ray's origin included, so the hit quad is a pure function of ray + replicated sprite state (observer-independent, deterministic on any simulator, and you can never hit one edge-on because you can never see one edge-on). 2D places use the renderer's fixed base poses (quad-orientation.ts). Hits carry objectId/position/normal/distance like any hit; the normal is the quad face sign-flipped toward the ray; outputs are f32-quantized. Opt out per call with { includeSprites: false } (byte-identical colliders-only path, pinned). Out of reach by design: camera-attached / parallax / repeatX sprites (camera-frame poses), near-invisible sprites (the shared opacity × tint-alpha render threshold), text/sign quads (DrawText, never DrawSprite), and shape sweeps. No per-texel alpha test — the authored quad is the hit surface.
  • One quad math, shared: the core ray-vs-oriented-quad test was extracted from the lume render-picking module (engine/lume/sprites/raycast.ts intersectSpriteQuad) and is now the single implementation under both the render picking path and the sim lane (tome/api/sprite-raycast.ts) — the two cannot diverge. Sharing exposed a latent sign flip in the mirror's billboard basis (right = −(worldUp×forward), opposite the vertex shader): invisible for centered quads but it mirrored the anchor origin offset, so default-anchor billboards ([0.5, 0] → oy = sy/2) picked BELOW their feet. Fixed to the shader's exact basis and pinned. Math.hypot was also replaced with correctly-rounded Math.sqrt chains (hypot rounding is implementation-defined across JS engines — now the math is realm-deterministic by IEEE-754).
  • Candidate pruning: the per-place spatial octree gains a padded ray-segment walk (SpatialOctree.queryRayInto — slab node prune + exact point-to-segment leaf test), padded by a world-level running max of hittable sprite quad radii (component hooks on DrawSprite/WorldScale/GeometryScale, mounted with the spatial-index hooks; never shrinks, so staleness only widens the walk). No O(all-sprites) scan per cast; per-candidate cost is the shared ~20-flop quad test behind a bounding-sphere reject.
  • Tests: realm-determinism pin (server and client worlds derive the byte-identical hit), ray-facing billboard semantics (two origins → two orientations, each facing its viewer), authored billboard:"none" orientation, opt-out byte-equality, text-defs-never-hit, octree pruning, miss cases, big-quad radius tracking, and the shader-parity billboard-basis sign pin.
  • Completed terrain builds now pass a currency check before installing (the voxel stale-install flicker — tiger's 5.2.4 walk finding, pre-existing since voxel-10x #6622): a full chunk rebuild carries a SUBMIT-TIME edit snapshot; when the chunk's inputs changed while the job was in flight (routine where automata block ticks interleave replicated edit echoes into chunks a player is building in — the /zoo ponds), Phase 1a/1b installed the stale output anyway, overwriting the voxel edit fast lane's same-tick remesh. On screen: the player's edit showed for 1–2 frames, a pre-edit mesh replaced it for a full job round trip (~23 frames ≈ 380 ms on the recording), then the convergence rebuild put it back — edit-direction-agnostic (place AND clear). Both build systems now compare a completed output's inputsHash against the chunk's CURRENT inputs at install time: unmarked chunks are current by construction (terrain-dirty marks are the exhaustive input-event source and park while a build is pending) and install with zero extra hash work; marked chunks re-evaluate (memoized under the mark stamp, so Phase 2 never pays twice); a stale output is discarded and the surviving mark resubmits with current inputs the same tick. Marked-but-hash-matching outputs still install — neighbour halo marks are routinely spurious under automata churn, and discarding on the mark alone would starve installs. No budgets, queues, or debounces: a correctness check, not a throttle.
  • Client twin guards the rendered mesh + local collider (client-terrain-system.ts Phase 1a/1b); server twin guards the authoritative collider (server-terrain-system.ts consumeCompletedServerJobs — a discarded build records a superseded build state, no failure warn/backoff). Place-level dirty flags now expand into per-chunk marks before completed jobs are consumed on both sides, so definition installs and place-mode flips are visible to the check like any per-chunk event.
  • Tests: deterministic A→B→A pin (full build in flight + mid-flight local edit → the stale completion must not overwrite the fast lane's newer output; red on parent), automata-echo discard + same-tick resubmit + convergence (client and server twins), and the spurious-halo-mark guard (marked-but-current completions still install; no churn loop).
  • Sprite metadata hydration respects client-authored sprite fields on avatars (ledger 1517): avatars (TomeBehaviorRef.specId: "player") now classify sprite authoredness by VALUE — the same lane as spec-less entities — instead of by the shared spec.player def. Avatar runtime writes are session-local by design (a player's skin never folds into the shared player def via the persist mirror), so def-derived authoredness was structurally blind to the owning client's writes: a brain wearing a hero at an explicit gameplay-constant size (the zoo's [1.1, 1.1]) got re-stamped to the mint's world_height guess ([1.7, 1.7]) by the upgrade pass (tiger's avatar small→big mid-walk, 2026-07-18). Value classification is the honest signal: hydration is the only writer of hydrated values, so any other value proves an author. Unsized avatar sprites (iso skins) still hydrate; spec-object sprites keep def-derived authoredness unchanged. authoredSpriteFieldsForDef returns undefined for "player"; the unconditional player-def indexing is gone.
  • Sizing/grounding authorship is per canonical art identity (buildSpriteValue, derive-appearance.ts): the writer's implicit whole-object merge no longer carries size/pixelsPerUnit/anchor across a canonical-identity change (facing/animated variants share identity and keep the carry; dotted writes restate fields explicitly and are unaffected). Without this, the engine's unconditional carry manufactured authorship the writer never expressed, and the value lane latched stale sizes across skin swaps: the zoo's farm→isoTown jump would have pinned the deliberately-unsized iso skin at the farm's [1.1, 1.1], and an unsized spec.player template iteration (the interpreter's player-diff whole-object write) would have latched each avatar's previous size. Both pinned red-first. Remaining residual, named honestly: a write that explicitly restates a stale value onto a new identity classifies as authored — which is what a restatement is.
  • Same disease family as the farm-dusk light flicker (spec-object light entities lack ClientEntity protection): engine-side re-derivation machinery writing over live client-owned runtime state. This change fixes only the sprite-hydration instance.

Engine v5.2.3

Released July 15, 2026

  • Fixed god mode getting stuck at an invisible floor in games with terrain turned off — you can now fly down to builds below height 0 instead of being silently held at the old ground level.
  • Fixed characters sometimes staying squashed after a fall — the un-squash now always completes, even on slower devices.
  • Recoloring or retexturing your terrain no longer hitches the whole game — tint and texture changes now apply in a blink (roughly 10× faster) instead of quietly rebuilding the entire landscape each time.
  • Retro pixel looks are razor sharp again. If your game renders at a chunky low resolution on purpose (PS1/N64 vibes), you get crisp pixel stairs back instead of the smoothed-soft image the new renderer's anti-aliasing was giving you.
  • Savi learned professional 3D tooling — she can now build game assets in Blender 5 (scattering, fracturing, tree generators, UV cleanup and more) and checks her own renders before the art lands in your game.
  • Rivers, ponds, and oceans can now wear fully custom looks — Savi can write a shader just for your liquid (glowing lava, cartoon water, murky swamp) instead of only styling the built-in water presets.
  • Art your game draws in code now works in your HUD and menus too — the same textures your scripts paint can show up as images in game UI.
  • The game quiets itself while you record a voice message, so your words come through clean instead of competing with your own soundtrack.
  • Sun shadows got cheaper while the camera moves — big outdoor scenes now spend noticeably less GPU drawing the same shadows, which means more headroom for everything else.
  • Animated art your game draws in code now animates in menus and HUDs too — a flickering torch or spinning coin painted by a script plays in your game UI the same way it plays in the world (it used to show only the first frame).
  • When one broken model spawns in lots of copies, you and Savi now get ONE clear error naming the model instead of a flood of identical errors — the flood used to bury the actual problem.
  • Trying to remove a player's custom look could silently bring their profile avatar back — it looked like the removal failed. Savi now gets told exactly what happened and how to actually clear it.
  • Savi edits game files with sharper, more reliable tools — fewer fumbled edits and clearer errors when a change doesn't land.
  • Savi can now restyle the parts of a 3D model separately — glowing brake lights on a car, a marble fountain with brass spouts, a new texture on just the roof — by naming the model's built-in parts. No model re-export, and it works on animated characters too.
  • Removing a mod now checks that nothing in your game still depends on its files first. If something does, you get a clear list of what to fix instead of a broken game.
  • Fixed multiplayer worlds sometimes refusing to spawn things for several seconds (or minutes) after someone alt-tabbed or hit a network blip — the world's host no longer loses its seat over a moment of turbulence.
  • Savi can now keep track of the walls, boxes, and props she builds with her quick shortcuts — hide-and-reveal effects (like roofs that peel away indoors) and interactive doors work reliably instead of silently losing track of pieces.
  • Savi builds better platformers on the first try — players standing on moving platforms now ride them smoothly instead of sliding off or overshooting at the turnaround.
  • Savi can now offer a "craft pass" on a part of your game that's grown messy from lots of small changes — same behavior, sturdier bones — and she'll tell you honestly when it's not needed.
  • Spawn something and use it right away — it just works now. Shots that fire the instant they spawn, portals that teleport on creation, chains of spawns acting on each other: no more "entity does not exist on the server" errors, and no need for little delays before acting.
  • Objects wearing legacy or unsupported materials now show up with a plain fallback look instead of turning invisible, and Savi is told exactly which object and material need re-authoring.
  • Grass, flowers, and terrain decorations now appear smoothly instead of popping at nearby distance bands, while using much less graphics memory.
  • Decoration slope and height rules plus clear-scatter zones now work consistently, and sprite decorations can use cropped texture regions with custom ground anchors.
  • Overlapping pixel-art trees and props now keep the correct front-to-back order as the camera moves.
  • Sound effects no longer crackle or pop when several loud loops play at once or when your scripts fade volumes and pitches every tick — loud mixes are caught by a safety limiter and volume changes glide instead of stepping.
  • Switching to another tab no longer makes the engine think your game is running slowly — no more false low-framerate alerts to Savi or your custom materials getting paused while you're away.
  • Your game can always tell who the current player is: the player's account id is now consistent across every script surface, in singleplayer too — no more empty ids that made per-player saves unreliable.
  • Fast-travel and portal trips no longer leave screen effects stuck on: temporary looks (like a warp overlay) and camera lens changes (like a widened FOV) from the previous place release automatically when you arrive, instead of persisting until a refresh.
  • A script bug that produces an invalid camera FOV can no longer permanently warp the view — the camera holds its last good lens and the problem is named in the logs.

technical notes

  • God mode no longer enforces a phantom terrain floor at y=0 in terrain-off games (ledger #1329, dump dd9ad1f0). The god-mode behavior duplicated the spec-side terrain predicate (spec.places[placeId]?.terrain !== undefined) which counted terrain: { kind: "off" } as terrain-on, while the interpreter removes the world terrain definition for kind:"off" — so getTerrainHeight returned 0 and the per-tick clamp snapped god-entity feet to y=0 forever (entry teleported the creator up; fly-mode descend netted zero every tick via same-tick snap-back; ascend still worked because the clamp only pushes up). The duplicate predicate is deleted: the behavior now derives terrain-liveness from the world's installed definitions via hasTerrain(world, placeId) (engine/features/terrain/alignment.ts), the same predicate every terrain system uses. Terrain-off (and failed-generator) places now take the designed no-terrain branch: parked hover in walk mode, free flight below y=0. Real-terrain places keep the clamp exactly as before. Pinned in no-terrain-floor.test.ts (entry below y=0, fly-descend across y=0, walk-mode park, real-terrain clamp control).
  • Presentation motion-juice restore is convergent instead of one-shot (ledger #1316, dig bda7b9f7). The squash/shake deformation channel (draw/motion-juice) refreshed per client frame while live but relied on exactly one component-REMOVE-triggered restore frame — and that REMOVE rides a droppable render-channel hop (frame decode failure, lost string refs, stream-reset snapshots that re-encode only still-live components). On degraded clients (quality ladder 5/5, halfRate, frame-budget fallback, tab supersession) a lost REMOVE left the composer's MotionJuiceState stale forever, recomposing the crushed pose over every later authoritative scale write — invisible to script-side scale guards because authoritative WorldScale was never wrong (presentation-only by design, ledger #951, unchanged). The composer now holds each deformation under a liveness lease (MOTION_JUICE_LEASE_MS, 1s of display time, renewed by every ingest): when the refresh stream goes silent without an ending frame, the deformation expires and the drawn pose converges to identity through the existing restore paths. Zero cost for entities without live deformation. Pinned in synthetic-transform-motion-juice.test.ts (lost-REMOVE squash/shake/hierarchy-child convergence + refresh-keeps-alive).
  • Shading-only terrain-materials fast path (#9171, ledger 1324): a materials delta that changes only shading fields (tint/color, texture refs, tile/textureScale, metalness, normal/roughness params) now swaps the material pack into the installed terrain definition in place instead of flipping the definition revision — the revision derives from a STRUCTURAL signature (material ids/order + generator source/deps + marks + seed + rooms — everything chunk builds and colliders actually consume), so a tint-class patchTerrain pays no whole-place reinstall, no dirty marks, no collider-readiness churn, no spec re-expansion or anchor/scatter refresh. The renderer re-reads the swapped pack through the existing per-place TerrainPlaceRenderConfig channel (content-hash deduped). Measured on the ledger-1323 driver's tint-tick phase: 220.06ms mean / 256.75ms max per call (20/20 ticks spiked) → 18.58ms mean / 26.53ms max, zero spikes. Structural changes and validation failures fall through to the full reinstall byte-identically.
  • Aesthetic-band renderResolution ceilings render raw pixels again (#9158, ledger 1322 — enfeul's First Light). Since 5.2.0 made TAAU the sole 3D production AA mode, games with an authored engine.graphics.renderResolution ceiling (the PS1/N64 look) went soft — Halton jitter + temporal history erase exactly the aliased stairs the look is made of. An authored ceiling with short axis ≤ 480 now declares raw-pixels intent: those 3D frames run the msaa topology (no jitter, no history, direct composite — the same production lane 2D scenes run), and the governor's renderScale rungs become inapplicable there, so nothing ever renders below the authored ceiling. Above-band ceilings (540/720/900/1080 — the perf-cap population) stay perf caps on TAAU; the authored↔unauthored edge invalidates TAAU history like the 2D↔3D cut. No new spec surface.
  • Savi's Blender-5 game-asset system (#9187): the blender-game-assets skill (hidden, runtime: chat) teaches natives-first professional 3D asset work — Blender 5.0 geometry-nodes scatter modifiers and SDF grid damage, 5.1 dilate/erode — over a vetted addon short-stack (BagaPie, Cell Fracture, Modular Tree, Mio3 UV, UniV) in the bpy dialect (append node groups, enable addons in-script), with game-ready export truths and a render-verify loop. Workshop upload lines become exact-URL read grants, so Savi can view her own rendered proofs the moment their URL prints. The savi-workshop image bumps Blender 4.2.22 → 5.1.2 alongside (companion-side), with an idempotent provisioning script for the vetted stack.
  • Scripted liquid shaders on terrain marks (#8749, plan p-520aae28): LiquidSpec.material?: ScriptedMaterialSpec — a river/pond/ocean mark's liquid can reference a Savi-authored scripted material, the exact { kind: "scripted", script, params } shape objects use. kind stays the substance (physics/audio/particles); material is look-only and replaces the whole liquid visual while the declarative knobs style the fallback. Attach/detach/param edits rebuild surfaces (the interpreter's liquid signature carries script + params), scripted refs on marks (top-level + every place) reach the renderer's material library, a parked/unclaimed scripted Water/Surface bespoke routes onto the water preset look (never the grey vertex-PBR lanes), scripted claims pass params only and keep castShadow false, and patchTerrain refuses a liquid.material.script that doesn't resolve.
  • scripts/ texture refs resolve as game-UI image sources (#9154): ui.js HTML now accepts texture-script refs in img src and style url() — the dom-host substitutes the baked blob URL (transparent placeholder until the bake lands, never a broken glyph), bakes ride a renderer-host RPC on the existing bake transport (same compile cache, budget rails, and park diagnostics as world bakes), and setTextureScriptLibrary's changed/retired delta re-bakes changed families live and revokes stale blob URLs. The drawn-art skill's HUD ban drops — the taught exception this deletes.
  • Game output ducks while the voice recorder holds the mic (#9152): speaker output ducks to 15% over a 50ms ramp while kiln's recorder is live, restoring over 200ms on release — game audio bled into the mic and poisoned voice-message transcription.
  • Per-cascade sun-shadow GPU culling (#9163, slices 1+2 of the moving-frame shadow plan): shadow select joins the lume GPU cull as a fourth variant classifying by occupancy + 6-plane cascade box only (no maxDistance, no LOD pKeep, no ring — the contract is "current set minus outside-the-box"), with per-cascade cull cameras, args ranges, and survivor buffers sharing group data with the main view, dispatched only for scheduled cascades whose view is dirty. Receipts (Apple M-series): cool-basis sun-cascade GPU −45% on the moving-frame bench, net −8.7% GPU on the valley pack at realistic load (dpr-3), GPU power −13% / energy −11%. Ships with the bench-shadow-moving rail: an always-orbiting UGC-shaped caster fixture and occurrence-correct per-pass windows (amortized cost over all frames, not just when-present means).
  • Internal/observability: the 5.2.0→5.2.2 tick-budget attribution dossier + full-stack A/B tick profiler (#9184, dig 47285eae); server_behind episode payloads carry room load counts + egress stage means (#9174, ledger 1326); netcode-egress bench TomeWorldReplicated registry fix + specimen-1326 config (#9173); perf-capture rig mutes audio by default (#9169); engine-version tooling's r2Read downloads via temp file — the prod catalog outgrew execSync's 1MB maxBuffer (#9147, ledger 1318).
  • Animated texture scripts play in game-UI imgs (#9222, the #9154 follow-on): an atlas bake that declares fps (ctx.atlas or the meta export — the same declaration a bare sprite plays world-side) now animates in ui.js HTML. The one deterministic bake additionally slices its default clip into per-frame PNG blobs renderer-side (off the page main thread), and the img lane ticks tracked <img> srcs between pre-made blob URLs — nothing re-bakes per frame, and the requestReapply pipeline stays first-arrival/invalidation only. Rails: declared fps clamps to 10 UI-side; 8 concurrently animated refs (overflow holds frame 0 + one loud fault on the ui-fault rail); 64 frames per clip (over-cap serves the still sheet + one renderer diagnostic, re-keyed on clip length so an edit re-reports). Ticking starts on fragment substitution, stops on the first tick with no live element, dies with dispose; zero timers for games with no animated refs.
  • Costume-keyed diagnostic emitters re-keyed to their CONDITION (#9228, the Shimmerwilds storm — 132 emissions in 90 min from one boneless model): model-not-animatable and the model-clip warns key on the MODEL (model-not-animatable:${modelId}, ref-only-key doctrine), entity id demoted to the data payload, so a population spawner minting fresh entity ids no longer turns one broken asset into an unbounded error-DM storm — the existing key#contentSignature dedupe collapses it to ≤1 admitted log entry per broken model per window and one hidden Savi turn. Transient sprite-atlas warns re-keyed per BASE in the same pass.
  • Authored-clip bind problems re-keyed per MODEL (#9237, same class): reportAuthoredClipProblems' three bind codes keyed (and worded) per entity; now keyed on state.modelId with the entity in the payload/console line only. The cap wraps the assembled variable suffix via diagnosticKeyRef at 172 chars so the longest prefix still fits MAX_KEY_LENGTH. Also fixes a stale physicsEngine-default comment.
  • New warn when a player-model clear resolves to the profile-avatar fallback (#9242, dig d3f2d32c — the removal that summons the thing): patchPlayer({properties:{model:null}}) is exactly ledger 936's fallback trigger, so Savi's removal verb re-summoned an avatar while her verification read honest nulls. warnPlayerAvatarFallbackRedress fires at the write transition only (setObjectProperty seam + patchPlayer/applySpec diff seam, gated on prev-template-had-body → next-template-bare — never ambient spawn-time, where account avatars are the legitimate normal), keyed per player per the #9228 convention, and the message teaches the cure (animated3DCharacter:false on the player template, plus that already-joined sessions keep the old body).
  • removed-api-tombstones grows 5 entries feeding the new chat-side draft-gate lint (#9200): tombstoned/phantom api calls are blocked at Savi's write time with the teaching error, before a broken script ever reaches the engine.
  • Savi's file tool surface renamed from the command-enum str_replace_editor to Claude Code-shaped Read/Write/Edit/Delete tools. Engine-side teaching text follows: skill sources (drawn-art, creator-ui, custom-materials, 3d-sprites, 2d-mode, workshop) and the runtime diagnostics that steer toward file creation (scripted material/texture/look script-not-found errors, the ObjectAPI memory side-door rejection) now name the Write/Edit tools. Rooms pinned to older engine versions keep the old steering text until this version reaches them; the live tool registry itself ships with the chat deploy, and a stale steer degrades to a recoverable unknown-tool error.
  • Material slots (#9213, plan p-04342ee6): model.materials — the object form paints PARTS of a model. Entries are keyed by the GLB's own material names (model: { id: "cdn/car.glb", materials: { BrakeLights: { emissive: "oklch(0.65 0.25 25)", emissiveIntensity: 4 } } }), mount Magic-CDN textures and packed PBR pairs onto named slots (materials: { marble: { texture: "cdn/texture-marble-white-veined.png", pbr: true } }), and are live-writable via setProperty("model.materials.marble.texture", …). Works on static and rigged (skinned) models; sharing is batch identity; swap-in follows the existing placeholder-then-swap law; a name that matches no material in the GLB reports instead of failing silently. Re-writing model without materials clears them. Savi's write-time PBR prewarm derives the packed pair for every material entry whose cdn raster texture and pbr flag share one object, so slot-mounted PBR doesn't cook on first client fetch.
  • Mod remove refuses while surviving scripts still require() the mod's files (#9261, badgerblunts/the-workshop 07-14): an uninstall deleted the mod's lib files in one write while ui.js + 3 other scripts still carried top-level require()s — ~3 minutes of [Tome] Module not compiled render failures until the dangling refs were cleaned by hand. remove now scans every surviving script's require()s (the adopt path's resolveRequireTarget, mirroring the kernel's caller-scoped resolution) against exactly the script keys the uninstall will delete; any ref that stops resolving refuses by default listing each dependent with its exact require, force: true removes anyway and reports the same list as a warning. Matches the family's safe-by-default shape.
  • Place-host assignment splits RETENTION from ELECTION (ledger #1327, dig efad3b28). isEligibleConnection — Streaming (or the never-streamed join window), attached, Ready — is the right bar for electing a NEW host (it must be able to take the make-before-break snapshot), but reconcilePlace used the same predicate to decide whether a SITTING host kept its TomePlaceHosts entry. Any transient transport boundary — the make-before-break NeedsReset its own assignment sets, a ws delivery-fail reset hold, a client self-heal reset, a hidden tab — fired the host the same tick, deleting the entry for an OCCUPIED place: every unowned create then rejected at the drain's table-stamp gate ("Tome spawn: place X is not resident"), the displaced host's in-flight writes died at the stale-epoch gate ("previous host epoch skipped"), and re-election at epoch+1 flipped NeedsReset again and cut another full-place snapshot into the same congested socket (a self-sustaining flap: 3 elections in 7s, then 10+ minutes unhosted, on live DD receipts). A sitting host now retains its entry — same clientId, same epoch — while its connection is Ready and attached, its PlaceMembership still names the place, it is not suspension-stamped past the takeover grace, it is not silent on both upload cadences past the existing health window, and it has not been off Streaming past the retention ceiling (HOST_NOT_STREAMING_RETENTION_CEILING_TICKS, 600 ticks ≈ 20s at the 30Hz default — the in-system bound for the heartbeats-pass/resets-fail backpressure wedge that input-frame health cannot see) (isRetainedSittingHost; no new timers, tick arithmetic only). Genuine session boundaries — detach, suspension-stamp, place-leave, disconnect, opt-out, sustained silence — revoke exactly as before, and election is unchanged (including ledger #1237's hostEligible: false gate).
  • All 34 builtin/primitives helpers return the spawned entity id (api.spawn's resolved return — parent/mod/place-qualified where applicable), enabling const roofId = p.box(...) capture for hide-lists, door registries, and destroy/patch calls. Previously the internal _spawn discarded the id. Skill teaching updated in the same change (the mangled-ids workaround is gone from the corpus).
  • platformer-movement skill teaches moving-platform carry as position-snap, never velocity-borrow (#9227, ledger 1341): riders inherit the platform's per-tick displacement by snapping with it, instead of borrowing platform velocity (which overshoots on direction changes and strands riders at endpoints).
  • Walk-independent iso-town teaching corrections from the eval (#9204): interactive-objects, structures, and 4 sibling skills correct the mis-teaches the iso-town eval surfaced.
  • New savi-craft skill (#9243, plan p-7074adc5): the creator-facing structural-rebuild pass behind the /skill command — find what's grown tangled through many small patches, map every behavior it carries, rebuild the minimal clean shape; honest no when the build is already sturdy. Includes the ambient-offer discipline (when to offer the rebuild unasked).
  • game-ui skill drops the desktop-only framing for Savi surfaces (#9220 — mobile is part of whole).
  • Spawn-then-act ordering contract: commands referencing a not-yet-known entity park server-side for a 3s create grace (a sim-tick window, stamped at first park and carried through re-parks — one window per command) and re-adjudicate through the full live authorization gate the tick the create lands, exactly as a network-delayed command would (generalizes the rail.destroy-only 1s park to entity-referencing commands; RAIL_DESTROY_GRACE_SECONDS deleted). Parks are bounded per client AND per room in count and bytes (oldest evicted loud at a room ceiling). The client's synthetic-create flush now walks TomeSpawnedBy ancestors, so same-tick spawn chains ship in program order. Commands referencing recently destroyed entities are dropped quietly via the despawn ledger instead of surfacing error DMs (client-uploaded despawns; entities destroyed by server-side commands still report with honest text until the ledger covers them — named follow-up); self-destroys whose create never arrived expire silently (the world already converged).
  • Batch-ineligible primitive materials (unknown/legacy material keys like "Scripted" without a script ref, wireframe, alphaTest, non-front side, unknown override keys, depthWrite:false while opaque) now draw a degraded Std/PBR fallback recipe instead of resolving a null representation — the 5.2.0 lume port skipped these classes to invisible (the three-era standalone mesh path drew them, deleted in #7602; #8830 fixed the pbr:true subclass, this covers the residue). The degraded resolve strips exactly the rejected bits and keeps every mappable param (color/map/tint/PBR scalars/transparency); flash appearance writes resolve through the same fallback.
  • The primitives-material-unsupported diagnostic now names the offending entity id and material key (or the exact offending override keys) in the message that reaches Savi's DM — the previous static generic text was unactionable (dig 9a59bc78: Savi answered "." to it or misattributed the invisibility to stale room state).
  • Rebuilt terrain decorations around compact 24-byte candidate records and 4-byte survivor refs, bytes-first adapter-limit admission, shared packed frustum culling, and live allocation-free quality uniforms. The measured desktop baseline requests 1.25× authored density across 1.75× distance; 3M candidates or the adapter's lower byte limit is a hard safety ceiling, while the default meadow builds about 1.07M. Tablet and phone remain unchanged.
  • Replaced hard near/mid/far population swaps with deterministic cumulative density strata and guarded root-collapse bands. Terrain tiles and newly ready sprite assets reveal through bounded birth ramps, wind/root transitions write real motion vectors, and stationary scenes return to zero decoration placement/cull work.
  • Decoration placement now honors authored slope/height ranges and terrain clear-scatter exclusions in addition to material, paint, noise, cluster, and water gates. Slopes use Spawn's normalized 0-flat to 1-vertical scale, with compatibility conversion for legacy degree values. Sprite decorations also accept normalized uvRect and anchor controls shared semantically with entity sprites.
  • Ground-cover is full through 175m on the measured desktop baseline and collapses to zero by 245m through a root transition. Cards grow modestly near the field rim to cover the remaining bare band without floating or scaling distant props.
  • Pixel-inferred 3D sprites such as Pixel Kart's trees again use depth-writing cutouts by default, eliminating camera-sensitive whole-batch ordering flips while explicit soft and linear sprites remain blended.
  • The SFX bus now routes through a DynamicsCompressor safety limiter into the master bus (ledger #1386) — the clip-voice mirror of the vibe sampler's safetyLimiter, same settings family (threshold −3dB, ratio 3:1, knee 8dB, attack 5ms, release 120ms). Script gains stay unclamped; stacked looping voices with gains >1 that previously summed past full scale and hard-clipped the destination (crackle/popping) now degrade gracefully through the limiter. Transparent on normal content: single voices at gain ≤1 sit below the −3dB threshold on the bus (and −9dB more headroom follows at the default master gain), so the limiter idles.
  • Clip-voice set-command updates (gain, pitch, reverb send) now land as an anchor plus 20ms linear ramp (CLIP_PARAM_RAMP_SEC) instead of instantaneous setValueAtTime steps — the same shape the vibe lane already used. Scripts that ease these params every tick no longer produce a waveform discontinuity per tick (zipper noise).
  • Also aboard from the jul-15 freight (merged without changesets): AOI rides the octree — per-tick O(world) egress scans are gone (#9210); lume slash-lane instance stride fix — CPU packed 256 B against the WGSL struct's 240 B (ledger 1306, #9292); the external-URL audio load-failure verdict tells the truth — no allowlist exists, live streams are a category error (#9298); cold-load 429s take a boot-window short-retry lane instead of the 5-minute tombstone park (ledger 1379, #9329); storage teach carries the user/<id>/ placement constraint in the server-context steer (ledger 1381, #9333); audio skill re-cut hook + AudioSpec breadcrumb so diegetic music asks route pre-load (#9339); wisp blender charters mechanically load blender-game-assets (#9330); spawn-then-act follow-up — #9251 review nits: name-based dispatch lookup, overflow-warn throttle, refs hygiene pin (#9343).

KNOWN OPEN (5.2.3 staging draft — minted 2026-07-14, sha last folded 2026-07-15 ~20:48Z with the jul-15 freight aboard; named honestly for the walk and the release packet; the draft:false flip is the train's separate human-gated step):

  • Ledger 1333 tick-budget hold — RESOLVED FALSE-READ, PROMOTE GATE LIFTED (2026-07-15): tucker's local bisect and a staging A/B canary agree — no regression rides this sha (the 3× read was an evolved-world comparison, "a poisoned test"); ledger 1333 closed as false-read, the #9026 suspect cleared. 5.2.3 promoted to prod 2026-07-15 (run 29464253946, green).
  • FrameBudgetGuard now treats hidden-tab time as non-evidence, in both hidden regimes. Throttled-flow (samples keep arriving ~1Hz): FrameBudgetSample.documentHidden (fed from the renderer worker's visibility state) takes the same early-return as the grace windows — flush the judged window, reset the fallback timers, drop the chronic span anchor. Fully-parked (no compositor begin-frames, zero samples): the visibility handler's hidden branch calls frameBudgetGuard.notePause() beside the governor's and hitch detector's, so a fallback timer armed by a pre-hide struggle can't bridge the gap and park instantly on the first post-return sample. A backgrounded tab's inflated presentedIntervalMs read as the idle-CPU GPU-bound shape (#199), so a few hidden minutes fired false frame-budget-warning chronic DMs to Savi and false fallback parks of scripted materials. The quality governor and hitch detector already kept this law; the guard was the one visibility-blind judge on the frame rail.
  • The visible edge of the visibility handler reseeds the renderer stats sampler (statsSampler.reset(), the same parked-tab-time law renderFrame's >2.5s loop-pause reseed keeps), so a short hidden gap or a throttled-flow tab's boundary window can never manufacture a gap-sized "presented interval" on return.
  • Player identity now resolves through one verified rail (resolvePlayerIdentity: server-populated TomePlayerProfilesResource, else owner-replicated SessionClient) on every surface — api.userId, getPlayers() (ObjectAPI, CameraAPI, and run_script exec), getProfile(), getUsername(). On client-simulated worlds (all behaviors in singleplayer; host-run world behaviors in multiplayer) the session's own player now reads the same real account id everywhere instead of null/"".
  • getProfile() no longer coins "" for unknown userIds and no longer parrots script-writable TomeState.userId as identity: PlayerProfile.userId is now optional — real where verified, absent where withheld. Other players' account ids stay off client worlds by design (SessionClient owner-only replication is unchanged); jsdoc + generated API reference now state that contract explicitly and route cross-player identity work to server context (lifecycle/cron).
  • Place-transition presentation reset (ledger 1387, dig 499c9122): duration-less api.pushLook layers — the one layer class with no scheduled end — are flagged clearOnPlaceTransition and begin their normal fade-out on spec-sync's placeChanged edge (client-side, netIngest, so destination arrival hooks that push layers land after the sweep). Duration'd pushes keep their armed cleanup timer; juice sugar layers stay engine-managed; atmosphere.look untouched.
  • enterPlace across places now removes the traveler's (and god mirror's) TomePlayerCameraOverride at the transfer — the session override previously persisted until session end, which parked a mid-ease setCamera({ fov }) on the player in the destination forever. applyPlaceSpawnRotation re-seeds the view direction after the clear; mint-transaction compensation restores the captured override exactly on rollback.
  • NaN guard on the renderer camera smoother's FOV chase: a non-finite ingested view.fov no longer poisons renderFov for the rest of the session (exponential filter memory) — the smoother holds the last finite fov, keeps rendering, and reports once through the engine-diagnostic rail (new log-only code camera-fov-nonfinite, visible in getLogs).

Engine v5.2.2

Released July 14, 2026

  • Fixed sprites and objects that had stopped moving randomly lurching a few pixels out and back in quiet scenes (most visible on parked critters in 2D games). Resting entities now hold their position exactly until they actually move again.
  • Savi can now see everything playing in your world — the soundtrack, every looping sound effect (with the handle to stop it), and every object with a sound attached — in one read, api.audio.playing(). Great for hunting down that one rogue sound.
  • If your game music points at a file that can't load, Savi and you now get told ("no client could load … Is the asset loadable?") and the game stops pretending it's playing — before, it reported a silent track as playing forever.
  • Asking to stop music when nothing is playing now says so instead of silently doing nothing.
  • Savi can now check the water level at any spot in your world. Before, asking the wrong way silently answered "no water" — even over a healthy ocean — which could make working water features look broken and send fixes chasing a problem that wasn't there.
  • Fixed worlds sometimes loading with a washed-out white/grey sky (fog and sky settings silently not applied) that stuck for the whole session until someone touched the atmosphere. The engine now re-asserts the sky on its own once the world finishes loading, and logs when a boot-time loss was healed.
  • Objects and effects no longer flash as dark solid shapes while their textures are still generating or loading — a decal, particle, or textured shape simply stays invisible until its art is ready, then appears correctly. If a texture fails to load, the object stays hidden instead of rendering as a wrong-looking solid block.
  • Fixed a failure where every custom material in a world could go grey/plain mid-session and only a page refresh brought them back. A renderer error is now contained to the one thing that failed — the rest of the scene keeps updating — and material rebuilds retry on their own.
  • Fixed players arriving in 2D scenes stuck facing the wrong way — rotation carried over from the previous area (or snapped toward the teleport jump) used to stick to the player sprite and its nameplate. Entering a 2D place now lands with clean facing, while rotations your scripts set on purpose still work.
  • Fixed lights and shadows sometimes jumping or looking wrong for a while after the renderer recovers from a GPU error. Shadow maps are re-rendered after a recovery instead of reusing stale copies.
  • Authored animation clips now play in multiplayer worlds. Clips you define as motion code (export function swim(t) played with updateChannel) used to work only in singleplayer — in a multiplayer room the character froze in rest pose while the console warned the clip wasn't found. The machine that runs the character now shares the sampled animation with the server and every player, so the same script animates everywhere, immediately for the player who triggered it and within a round trip for everyone else.

technical notes

  • The client atmosphere apply is at-least-once, engine-owned (ledger 1297: session-permanent washed white/grey sky when ONE row was swallowed during the fresh-room boot window — deterministic for affected boots, value-independent, healed by any live patchAtmosphere). Every stage of the apply is change-gated (lastHash/lastAtmosphereRef latches, the lastLookRef look-library ensure, plainValueEquals on the skybox row, the ECS structural-shallow equals gate), so a boot-window loss made every later pass read the atmosphere as already-applied. Now, once per render-channel epoch, when world-residency first reads true — the renderer is provably up and quiet — tome/systems/atmosphere-sync.ts resets its apply latches and force-re-emits every live atmosphere row (sun/ambient/hemisphere lights, fog, skybox, cloud/star sprites, transforms, and the LookScripts library) via markComponentUpdated, the engine's value-identical force-emit — the honest version of the creator-space ~1e-7 fog-density-jitter bandaid, never a value perturbation. The epoch signal rides the existing sim-side renderer seam (tome/world-residency.ts), bumped by the runtime worker on every render-channel attach; steady state pays two module reads and one compare per pass, the re-assert never repeats inside an epoch, and a renderer-worker swap (new channel epoch) re-asserts once more. 2D and 3D ride the same system and channel; no new spec surface.
  • Boot-window sky losses are LOUD now: an UPDATE for draw/skybox or draw/fog arriving while the lume store holds no active entity means the session's first apply (the ADD) never landed — the renderer handlers report atmosphere-apply-lost through the existing engine-diagnostic rail (allowlisted, log-only: the arriving row already healed the sky), once per component per renderer session. The code also rides SERVER_CONSOLE_BREADCRUMB_CODES, so the accepted report prints one server-console line per room per deduped condition — the kernel container's stdout is what observability ingests — making the class visible to BOTH getLogs and DD (fleet-level heal counts validate the fix in prod).
  • Script-authored animation clips (#7986) now deliver in MULTIPLAYER rooms (ledger 899 — the frozen 5.1.11 zoo swimmer). The mechanism: in client-auth multiplayer the server simulates nothing — a hosted NPC's updateChannel(fn) play executes on the PLACE HOST (a client), and canMintAuthoredClips correctly forbids clients from writing the spec. The host resolved the identity string only; the identity fanned out through its DrawMixer upload while the sampled tracks existed nowhere, so every renderer warned clip "…#swim" not found forever. The played function is typically a closure over the simulator's own rig reads (getBones() names/amplitudes — the taught rig-adaptive pattern), so no other machine can re-derive it from script source; the sampled ENTRY has to travel. Fix, all on existing rails: the simulating client samples locally (its own renderer plays the clip immediately, 0-RTT), stages the entry as a new AuthoredClipOffers component on the playing entity, and the offer rides its normal StateDeltas upload; the server re-validates the untrusted entry from scratch (structure, caps, contentHash recomputed — never trusted) and mints it through the existing pending→fold path (single spec writer intact, mint-velocity guard included); TomeSpec replication fans the entry to every client; the simulator sweeps the offer once the replicated spec carries it at the same hash (ratification ack — offers never linger in snapshots). Malformed offers reject loudly once (runtime-log warn, memoized) and never reach the spec. Singleplayer and server-side plays are untouched (direct mint, no offers). Pinned end-to-end through the production client-auth harness: real host election, real upload validation, real fan-out, real sweep — the delivery assert fails on master. Offers respect the room wire's per-component-value budget at stage time (MAX_STATE_DELTA_VALUE_BYTES, 64KiB — enforced decode-side only, where a breach rejects the simulator's ENTIRE delta message for the tick, delivering nothing and warning nowhere client-side): a legal-by-float-caps clip that would serialize past the budget (a full rig at defaults gets there) is REFUSED with a creator-visible runtime-log warn naming the clip, the size, the limit, and the cure (shorter duration / lower fps / fewer tracks); the refusal is memoized per content so a re-resolving channel never re-offers or re-warns per tick, and the simulator's own renderer keeps its local play. Known residual gaps, stated honestly: raw-tracks plays ({ name, tracks }) from a client simulator deliver through the same offers path, but the offer path as a whole only runs where a play call executes — places nobody hosts stay paused (unchanged semantics). And the offer IDENTITY is unbound to the offering simulator in client-auth: a modified client that simulates any entity can overwrite an existing scriptRef#fn entry with valid-shaped junk of its own, or squat identities toward the 48-clip cap — bounded by the per-value wire budget × the clip cap and by full server-side re-validation, and a legit re-sample is indistinguishable from a hostile overwrite, so client-auth cannot close it airtight; a per-client offer budget and a purge story for squatted entries are fast-follow material.
  • api.getWaterLevelAt(x, z, place?) — the world point query for liquid height (#9142, ledger 1315): wraps the engine-internal getLiquidHeightAtPoint over resolved marks (revision-cached per place) plus the voxel liquid sampler (topmost surface in the column), and works from ANY calling context — exec entities included. Fills the instrument gap that produced false "liquid system dead" verdicts: getLiquidLevel takes no arguments (it's entity-bound and only answers for entities the liquid detector tracks via onLiquidEnter/onLiquidExit), so probing open water with getLiquidLevel(x, z) from run_script's untracked exec entity silently swallowed the args and answered null in every game. getLiquidLevel called WITH arguments now records a getLogs-visible warn (throttled per entity) pointing at getWaterLevelAt. Docs ride types.ts JSDoc → api-reference §Terrain/§Movement via the generator; zero always-on prompt tokens.
  • Music load-failure honesty (music-apex design D2.1): music.play of a plausible-but-unloadable ref used to be a silent forever-lie — clients failed the load with console-only errors while music.now() reported the dead layer as playing for the rest of the session (loop defaults true; the duration never mirrors). Clients now report a trust-gated load-failed verdict on the existing engine.soundDuration wire (authority-referenced clips only, same per-connection rate budget, first-verdict-wins; fired only on TERMINAL failure — 3 real attempts or an unresolvable ref, never for Magic-CDN still-generating/pre-session parks). On verdict the authority marks the layer failed in replicated state, writes a creator-visible runtime log (music layer "default": no client could load "<ref>". Is the asset loadable?), and every read excludes it (music.now(), the census, client playback); the next verb write prunes it. A fresh music.play of the same clip re-arms the verdict (fresh chance after an asset fix; failing clients re-offer on a 30s per-clip cooldown, so a still-broken clip is re-told). Deliberately verdict-driven, never TTL-driven: a bare timeout would false-kill honest music in empty rooms and during long Magic-CDN generation. A known duration always beats a verdict (the clip provably loaded somewhere) — order-independently: a trusted duration arriving AFTER an accepted verdict clears it, revives the failed layers with an epoch bump (clients rebuild the voice mid-clip), and tells the creator the earlier warn is void.
  • Zero-match music.stop is TOLD (design D2.2): stopping when nothing is playing — or naming a layer that isn't live — now writes a mutationWarn teaching line into the runtime log (music.stop: nothing was playing (music.now() => null) — no-op / no live layer "x"), once per (entity, kind) with value-free dedupe keys. The miss path still writes no replicated state.
  • The audio census (design D3): api.audio.playing(): AudioCensus — one read, any realm, returning { music, loops, emitters, shifts }. music === music.now(); loops enumerates live playSound(loop:true) voices as { soundId, ref, entityId?, bus } — previously enumerable by NO read anywhere (the returned soundId was the only handle and the start event expired after 180 ticks); emitters lists standing audio: components as { entityId, ref, bus, spatial } (vibes report their script path; paused vibes excluded; gain-0 emitters included deliberately — the hidden-rig hunt is the point); shifts lists the standing legacy per-listener musicShift/ambience channels as { entityId, music: {clip,volume}|null, ambience: {clip,volume}|null } (TomePlayerJuiceState, one entry per listener with an audible channel — the second-largest audio population in prod, previously the census's one blind spot). Census = authority truth (what the server told clients to play); client-local hatch audio deliberately excluded. Backed by a new replicated loop-voice registry (TomeSoundLoops on tome/spec, replicate:"always" / clientAuthWrite:"never" — the TomeMusicState chassis), written at the verb sites on authority worlds and by a server-side juice-event sweep for client-auth-authored loops; capped at 128 entries (oldest evicts, ops-logged). Standing loops are now snapshot-visible to late joiners (their enumerability no longer depends on catching a 180-tick event window).
  • run_script declaration collisions with injected globals teach (design D1.6): const music = … used to die with a stock Identifier 'music' has already been declared / Cannot declare a const variable twice and teach nothing (the Waystead specimen — Savi renamed her variable and never learned the namespace existed). The exec error now appends what the global is: 'music' is the engine music API (music.play/stop/now — audio skill); rename your variable. Original error text preserved; user-vs-user redeclarations untouched.
  • music.duck now carries the same settled-layer prune every other music verb write performs (the documented prune-on-verb-write contract; previously duck writes skipped it).
  • Pending/failed DECLARED textures render nothing — never an untextured solid (the QA "Paws of Fury" P1: a decal plane with a still-cooking alpha-mask map drew its whole plane as a 0.85-alpha dark slab, and blend:"alpha" dust/petal/KO sprites drew unmasked dark quads while art streamed). Two sites, same law:
    • Primitives (batched lanes, primitive entities + bespoke mesh slots): a slot whose declared albedo (map) has no resident canonical asset carries PrimitiveTextureState.albedoPending and is not presentable — excluded from cull-bounds occupancy (radius −1), draw windows, shadow survivors, picks, and outline masks — instead of resolving to the masked layer 0 and drawing solid instance color. Residency is the gate, not placement fidelity: budget/profile flat-floor degradations of RESIDENT textures keep drawing exactly as before, layer-0 "no texture declared" solid-color semantics are byte-identical, and pending normal/height maps still stream in without hiding the instance (shading detail, not coverage). Zero per-frame cost: the bit is written at reconcile time and flows through the existing visibility rails.
    • FxVM sprite batches (GPU indirect + CPU snapshot paths): collectDraws skips a batch until its declared texture is applied (single-texture lease resident, or composited pack ready), instead of drawing with the opaque-white 1×1 fallback that the fragment shader's curve-alpha × tex.a multiply turns into solid quads. Skip-draw rather than a transparent fallback texture: no fragment cost, no blend-mode edge cases. The white fallback and pack placeholder remain as bind-group filler only and are never rendered; a texture that never arrives (silent magic-cdn failure) now renders nothing forever instead of a slab forever.
  • Crossing into a 2D-mode place now resets the player's rotation to identity unless the place's authored spawn rotation actually applied (#9125, the farm-dusk stuck-rotation walk find, 07-13). A player's 3D heading yaw is meaningless in 2D and nothing in 2D ever rewrites it (facing is flipX/clip/texture-swap vocabulary), so whatever rotation the traveler carried at switch time parked on the entity forever — replicated to every client and displayed, since the 2D quad pin passes entity twist through (sprite and parented nameplate spin together). The reset gates on an actual place change and on the spawn rotation having been WRITTEN, not merely authored — same-place respawns keep script-set yaw, and an authored-but-unresolvable rotation can't suppress the reset. Non-players keep their rotation (authored yaw on 2D objects — top-down cars, ships — is the twist pass-through's whole point).
  • The animated-character locomotion/facing feature forgets its previous-position state when an entity's place changes: a place transfer teleports the entity, and a prev-pos spanning the teleport read the jump as phantom velocity — the facing system wrote the jump direction as yaw on the first post-transfer tick, and locomotion read the same jump as a speed spike.
  • A throwing renderer handler is now contained to its own lane (#9128, ledger 1307 — izkimar's dump 6fb847c7: whole-scene Std/PBR material mute mid-session that neither a script edit nor the join watchdog could wake, only a tab refresh healed). Frame-prep ops are drained destructively from the SAB ring, so one handler throw used to drop the whole frame's drained ops for EVERY handler — never re-applied, silently desyncing the renderer's stores from the sim for the rest of the session, deduped to a single frame-loop error. Each handler's collect now runs in its own try/catch: a failing handler degrades its own lane for one frame, every other handler keeps applying, the frame still renders, and the failure reports once per (handler, message) on the existing frame-loop diagnostic rail.
  • The scripted-material refresh retries instead of stranding: the material-scripts handler swapped the library BEFORE the refresh walk, so an aborted walk left library="current" with the scene still muted — identical re-deliveries (edit heals, join-watchdog re-requests) diffed to zero changed refs and never rebuilt anything. Changed refs now queue before the walk and drain on every collect: a throwing refresh retries next frame (idempotent re-refresh), bounded at 8 attempts, then one loud named gave-up diagnostic instead of a silent walk per frame forever.
  • Reverted 5.2.0's adaptive remote-view playout (#8339, squash 4f96889a; dig 8151b070, jacob's Waystead field report). The depth estimator read scheduled server quietness as feed lateness — the server skips empty StateDeltas, so a quiet scene flaps the overdue gauge constantly — and while overdue, tickStaleRecord un-converged every held transform ring and dead-reckoned it along its newest snapshot-pair velocity (up to REMOTE_STREAM_EXTRAPOLATION_TICKS = 2.5 ticks). A hop-and-stop critter parks with full pre-stop velocity in that pair, so every flap lurched the parked sprite 5–10px along its last hop vector, then eased it back when the feed caught up. Remote-view presentation returns to exact pre-#8339 semantics: fixed 1-tick playout delay, clock extrapolation bounded to 0.25 tick for rings current to the feed head, and the resting-entity law — a ring stale versus the head holds its newest pose and never extrapolates. The burst-move-then-rest scenario is pinned in synthetic-transform-delta.test.ts as the acceptance criterion for any adaptive-playout re-land (tucker's redesign).
  • GPU validation-burst recovery now distrusts cached shadow depth (#9124, the zoo lights walk — ledger-1303 residue present since the 5.2.0 lume cut, not a 5.2.1 regression). The frame is one submit, so each validation-invalid encoder in a burst discarded shadow-atlas and sun-cascade render passes the scheduler had already recorded as rendered — after recovery, lights sampled depth from before the burst (or from a prior slot owner) until the ordinary refresh aged every face out, which read as lights jumping or shadows going wrong for a stretch. Recovery now calls ShadowAtlasScheduler.invalidateCachedFaces() and forces the next sun-cascade renders — pure CPU flag writes, hoisted above the disposals so a dispose throw can never skip them. Slots, fades, and assignments stay put; every cached face re-renders through the ordinary per-frame shadow budget.

Engine v5.2.1

Released July 14, 2026

  • Growing black patches are gone. On some machines, dark blotches could appear on screen (worst at night) and spread until they swallowed the whole view — reloading didn't help. Fixed at the source, for good.
  • The realistic night sky no longer shows irregular black patches after dark — stars and the Milky Way shine everywhere they should.
  • Walls, floors, and objects with realistic (PBR) textures stopped showing up after the 5.2 update — they render again, textures and all.
  • Scripts that do heavy work in bursts (every few ticks) used to slip past the safety net and could grind your game to a halt until the tab crashed. The engine now catches them within seconds and pauses them with advice that actually fixes the problem.
  • If the game picture ever freezes while the game itself keeps running underneath, you now get a clear "reload" prompt within seconds — no more silently stuck screens you have to diagnose yourself.
  • The volume slider works every time now — it applies immediately to all game sound, including sounds already playing and music or videos inside game menus.
  • Two looks that quietly changed in 5.2 are back to how you authored them: glowing objects (suns, halos) can shine through fog again when you told them to, and see-through cutout surfaces once again hide what's behind them.
  • Models no longer get stuck as placeholder rings after a room hiccup — when the connection comes back, everything finishes loading on its own.
  • Ask Savi for a "free cursor plus hold right-click to look around" camera and she gets it right now.
  • Right-click selection works on every object again, whatever material it wears — objects with custom scripted looks had stopped being selectable in build mode.
  • Savi can now convert a world you've already built to a different starter kind (like 2D ↔ 3D) — you see exactly what would change before agreeing, your creations survive, and any starter scripts you'd edited are parked for reference instead of deleted.
  • "Fires once" effects — fire-pit coal beds, one-shot bursts — no longer vanish permanently for players who joined while the world was still warming up.
  • Built-in rain, sparks, and fireworks read as real motion streaks now instead of a wall of tilted cards.
  • Converting a 2D tile place back to heightmap terrain no longer flattens the ground to zero wherever tiles had been painted.
  • Pressing interact no longer triggers an action twice when a key and a controller or touch gesture share the same binding.
  • Cliffs and steep terrain show their real rock strata again instead of vertically stretched streaks.
  • The blur effect works again — death flashes, dash juice, and scripted blur looks that went quietly sharp in 5.2 are back.
  • Big lakes no longer draw over nearby bushes and shore objects at certain camera angles.
  • Scripted looks on model parts and decoration layers now survive save and reload instead of silently reverting.
  • Slash and shockwave effects no longer flash a black wedge from certain angles.
  • Objects anchored to terrain stop visibly bouncing on other players' screens while terrain is edited live.
  • Name tags and floating text stay crisp while whatever they're attached to runs around — no more smeared, doubled-up labels on moving players.
  • Savi no longer gets falsely told a texture script "was not found" after you switch objects off it — retired paint scripts step aside quietly, keeping whatever they last drew on screen.
  • Switching between games in the same tab no longer wedges the world when the previous session was a live/play session — the engine cleanly resets to the new game instead of refusing its updates.
  • Camera scripts that set their own yaw/pitch every tick — locking the camera on your killer during a death animation, cinematic snaps, forced framing — now actually control the screen again in mouse-look games. The engine used to keep following the mouse and silently ignore what the script wrote; now the script wins the moment it disagrees, and normal mouse look stays as smooth as before.
  • When your game's renderer dies mid-session — including the "GPU error storm" way, where the screen silently freezes while everything else looks alive — you now get an honest reload prompt within about a minute instead of never, and Savi is told your screen is frozen so she stops narrating changes you can't see and suggests a refresh instead.
  • Worlds heavy on foliage, fences, grates, and other see-through-cutout surfaces render faster — especially on phones and tablets — with zero visual change.
  • Fixed a family of silent failures when addressing objects by the wrong name: deleting or editing an object that doesn't exist now says so (and if you used a list position like "5", the message names the object actually sitting there); reading the spec by list position tells you the object's real id; and objects attached to the camera (HUD elements, music emitters) now show up when you list objects by tag.
  • Fixed a bug where a player whose tab sat hidden through host changes (or who was mid-session when the room restarted) could get permanently out of sync — ghost objects only they could see, builds half-applied — until they manually reloaded. The server now detects the stuck session and quietly re-syncs it within seconds; nobody has to leave and rejoin.
  • Rewriting painted fields through spec edits (or cancelling a brush stroke) now updates everything that reads them at once: the terrain mesh, NPC pathing, terrain-anchored props, scatter beds and their footprint ribbon, instanced scatter, and painted foliage density all pick up the new paint immediately instead of keeping the old state until something else changed.
  • Some iPads and phones were being drawn blurry and sparse for no reason: when the browser itself limits how fast frames arrive, the engine kept cutting visual quality trying to fix a slowdown the graphics weren't causing. It now recognizes that situation, gives the quality back, and keeps the game exactly as smooth as the browser allows.
  • When a device limits its own frame rate (Low Power Mode, battery saver, browser throttling), the player now sees one small note saying so, and Savi gets told it's the device's environment — not the game — so neither of you burns an evening debugging content that was never the problem.
  • Fixed a shimmer where nested see-through objects (like layered translucent shells around one center) rapidly flickered over each other once they split into multiple materials — the draw order between such layers is now steady frame to frame.
  • Fixed a bug where a mod's on-screen HUD (buttons, sliders) stopped working after installing the mod into another game, even though it worked fine in the mod's own dev game — HUD button presses now reach the mod's scripts in fresh installs.
  • NPCs now see the hills you sculpt: units path over (and get blocked by) god-mode-raised terrain instead of walking through it at ground level, and re-route as soon as you keep sculpting.
  • Fixed a bug where a look pushed by a script (fog, murk, color grades via pushLook) could permanently fail to appear — with a wrong "script not found" error — even though the script file existed. Look edits now show up, and the error only appears when the script really doesn't exist.
  • Games whose scripts persist changes every tick — spawning crops, saving building edits, updating persistent object properties — no longer freeze for seconds when their world re-syncs: a re-apply of content the game already has now takes ~2ms instead of re-processing every object, and the save-echo that used to push a whole new world revision to every player is recognized and suppressed.
  • Pixel-art sprites no longer shimmer or crawl at fractional camera zooms — texels stay crisp and stable while the camera pans, at every zoom level instead of only exact integer scales.
  • A leftover broken terrain-mark entry in your game's data can no longer crash the whole server over and over (it used to force restarts and spam error DMs). The engine now skips the broken entry, keeps your rivers, ponds, and roads working, and logs which entry to delete — and the way it used to sneak into your game (deleting a terrain mark that didn't exist) is fixed at the source.
  • Savi now finds out when one of your game's textures fails to load or draw on players' screens — before, those failures only appeared in the player's browser console, so she could confidently ship a broken visual without knowing. She also stops promising that a script interrupted by a hidden game tab will run by itself later: she'll tell you plainly the change hasn't happened and ask you to keep the game on screen so she can retry.
  • Tiles your game's scripts dig, sow, or paint now stick in multiplayer: they reach the server and every other player, survive rejoins and host changes, and save with the world — no more garden beds wiped back to grass mid-session.
  • 2D tilemaps no longer show faint vertical or horizontal seam lines at certain zoom levels — tile edges stay clean at every camera zoom instead of occasionally sampling a sliver of the neighboring tile in the tileset.
  • Fixed a bug where leaving a testing/monitoring client alone in a multiplayer room could freeze the game world and make Savi's script runs time out — such clients are no longer put in charge of simulating the world, and rooms recover the moment a real player walks in.
  • Fixed props with custom-shaped colliders sometimes losing their real collision shape for a whole session (players could clip into trees, rocks, and vehicles that looked solid). The engine now recovers the real shape instead of getting stuck retrying a dead-end fetch.
  • Physics objects that end up turned exactly halfway around (180°) no longer read back facing the wrong way.

Known issues (being worked on staging right now — this version stays in testing until the first one is fixed):

  • Houses and other multi-part builds can visibly wobble between two positions. This one blocks the release: the version won't go out while it's happening.
  • Fast-moving flat sprites (2.5D characters and props) can look blurry or ghosted in motion — a top-priority fix is underway in its own thread.
  • Some floating text can still smear while moving (name tags over players are already fixed in this version; the rest is in progress).
  • On flat worlds you can sometimes see where the ground stops being drawn at the far horizon — a known design limit being redesigned.
  • Worlds with lots of sounds recover automatically instead of going silent.
  • Grass and ground cover now reach the horizon on desktop.
  • Distant grass fills the horizon better on flat worlds: blades near the edge of the grass field stand a little taller, so the field covers the bare ground beyond it instead of stopping in a visible line.
  • World labels stay sharper and steadier while the camera or their parent moves, and tiny distant text fades away cleanly instead of turning into shimmer.
  • In 2D worlds, text can use sortingLayer and sortingOrder to sit between sprite layers, while ordinary signs and unlayered labels still obey walls and other foreground geometry.
  • Reverted the camera divergence-adoption gate from this release — it caused a stutter/jump-back while rotating in games with custom camera scripts. Custom camera scripts that write yaw/pitch every tick return in a later release with an explicit authority signal.
  • Layered flat effects (cracks, decals, panels) now stack in the right order from every camera angle.
  • Fixed brand-new worlds crashing on their first load when dense ground decoration was enabled.

technical notes

  • Killed the TAAU history NaN-poisoning class (ledger 1195): one non-finite scene texel entered the temporal resolve, was written to history, and the Catmull-Rom resample + neighborhood taps spread it ~2 texels/frame until it consumed the canvas — reload-immune, since it re-seeded deterministically. The resolve (lume/post/taau.ts) now scrubs non-finite values at all three feedback boundaries: non-finite scene taps carry zero weight in the 3×3 filter and the moments, a non-finite history resample collapses to the current frame's filtered color, and nothing non-finite is ever written to history (branchless self-equality selects + a 65504 ceiling for ∞ in rgba16float).
  • Hardened the known seed sites: all three wave-derived normalize() calls in water-wgsl.ts route through a safe-normalize with an up-vector fallback, and the bloom prefilter (bloom.ts) stops relying on WGSL max() NaN semantics — the vendor lottery where spec-faithful adapters propagate NaN and the "scrub" was a no-op.
  • Clamped five fBM remaps in the realistic-sky night stack to the [0, 1] domain the surrounding math assumes (3 in the night bake sky-night.ts, 2 in the moon layer sky-radiance.ts): 4–5-octave fractal noise swings past ±1, and feeding the remap into WGSL pow() is indeterminate for negative bases — NaN texels baked into the night sky texture blacked out everything at that view direction, stars included (ledger 1185). Day and twilight rendering unchanged; the out-of-domain texels become deterministic and finite on every adapter.
  • pbr: true textured primitives draw on lume again (ledger 1191): normalizeMaterialSpec canonicalizes pbr: true into a spent overrides.pbr marker, and the batch gate (SOLID_BATCH_OVERRIDE_KEYS in lume/primitives/store.ts) rejected the marker — recipe null, entity silently skipped, zero fetches. The marker is now accepted at the gate. The skip's diagnostic primitives-material-unsupported is allowlisted (it was emitted since the lume port but server-dropped), and a pbr: true cube exhibit joined the zoo's primitives zone.
  • The behavior park watchdog gains a rolling-window trigger (ledger 1197): it still parks on 3 consecutive over-budget ticks, and now also parks when total over-budget excess across the last 3 seconds of ticks passes 20 budgets on ≥2 ticks that each overflowed their full tick interval. Duty-cycled heavy lanes (if (tick % 3 === 0) around a 1.5s step) used to run unthrottled until Chrome killed the tab; they now park on their second heavy beat. A single spike of any magnitude never parks (one-time init stays safe), and sustained just-under-budget work bills nothing.
  • The watchdog's nudge text teaches total-budget pacing ("spreading the work out doesn't reduce the total — shrink it") instead of chunk-scheduling, which was coaching scripts into exactly the evasion the old consecutive-only trigger couldn't see. The tome.behavior.budget_parked telemetry line now carries the trigger and window numbers.
  • Present-heartbeat watchdog (ledger 1186): a visible canvas whose frame loop stops posting its ~1Hz heartbeat for 30s while other worker messages still flow now fires the loading-state reload prompt, a renderer-present-stall engine diagnostic for Savi, and a DD-countable render_present_stall console error — the frozen-canvas-while-worker-alive class was previously invisible to every watchdog (they all ticked inside the dead render loop) and unlogged in prod. Fires at most once per session; hidden/occluded pages and never-stamped old bundles are never judged.
  • Frame-prep errors name their profiler step: the frame profiler keeps an always-on active-step stack, and the render-loop diagnostic's catch now carries frameStep (stream/read-ops, handler/*, assets/tick, …) in the console line, creator-facing message, and data — the next caught frame-prep throw names its subsystem outright instead of needing a fleet dig.
  • User volume settings apply unconditionally (ledger 1187, standing since 5.1): bus gains were applied only by rewriting bus commands inside live audio.update lists, so when the prep loop swapped empty lists or stopped swapping, no volume change could land while already-playing WebAudio kept ringing at old gains. WebAudioRendererHandle grows setBusGain, and the renderer host subscribes the six bus gains directly to the user sound settings store — main-thread, worker-independent, applied at attach and on every change. The old command path stays; both compute gains from the same computeUserBusGains().
  • Tome-UI DOM <audio>/<video> elements now obey master volume (authored volume × master, via a MutationObserver in dom-host.ts) — they previously played at element volume entirely outside the bus graph. Kiln's default master aligned to the engine's deliberate 0.35 (the slider showed 50% while sessions actually played at 0.35).
  • Restored two silent 5.1→5.2 scripted-material parity breaks (dig ac9a3f41), both "author's instruction silently ignored" class:
  • material.fog = false is honored again: fog is real ShadeMaterial render state (the assignment used to land on a dead expando while assembly fogged every material unconditionally). The assembler compiles the fog mix out when fog = false, and the bit rides the pipeline key — an additive sun in dense fog can no longer be swallowed to a silhouette.
  • Authored depthWrite: true on blended materials is honored when alphaTest > 0 (the cutout discard-then-depth-write idiom, e.g. dome shells occluding interiors) — exact 5.1 semantics for the cutout class. A blended material with alphaTest === 0 still gets the alpha-bucket contract's depth-write override; the policy is documented at the site.
  • Room reattach re-arms terminally parked model retries (ledger 1183): RendererAssetService latches retryAfter = Infinity after the dead-4xx budget or pre-session park cap — reachable purely from outage artifacts during a room container restart, after which the models never re-requested for the life of the process. The authenticated attach now relays through the host chain to assets.service.notifyRoomAttached(), which clears exactly the infinite parks: one re-probe per re-armed id per successful attach, the ladder's own backoff governs from there. Finite clocks and server-stated windows (quota deadlines, cook-kill tombstones) are untouched.
  • Camera skills teach pointer-lock truthfully (r-224, skill sources only): camera-first-person.md no longer claims mouse look needs pointerLock: true (mouse-delta axes feed whenever a button is held), teaches the first-class mixed mode — cursor game + hold-to-look via edge-triggered hideCursor()/showCursor() — and warns that cursor verbs are last-write-wins within a frame (a per-frame showCursor() silently stomps an edge-triggered hide).
  • God-mode pick ray sees scripted-material meshes again (ledger 1201, 5.2.0/lume regression): a scripted-material binding swapped the entity to the standalone scripted lane (lume/primitives/scripted.ts), which kept GPU-side geometry only — the CPU renderer raycast walked only the batched lanes + oversized pools, so the ray passed straight through and selected terrain behind. The scripted lane now retains a CPU pick source (zero-copy position + index references, local AABB) with a lazily built BVH, and raycastPrimitives / primitiveWorldBounds traverse it alongside the lanes. The pick source is minted from geometry alone, independent of the material record — a still-compiling, parked, or failed binding picks identically to a healthy one; bespoke meshes ride the same lane. The stale "falls through to the sim pick" fiction is removed from the docs.
  • replaceSpecWithStarter grows a convert mode for built worlds (plan p-7d8c9abb): replaceSpecWithStarter(id, { convert: true, dryRun: true }) returns a manifest of exactly what would change with zero writes; { convert: true, consent: {...} } executes the consented conversion. The merge is a pure function of (birth seed × target seed × current spec) with one law throughout — a facet is register-bound iff the seeds disagree on it: register-bound authored config replaces with a migration record, edited seeded files park under scripts/parked/<birthId>/ (never deleted), register-agnostic and creator work survives, absent seeded pieces never resurrect. Lands mode/gravity/camera/inputs/ground/embodiment explicitly and regenerates lineage plus a fresh authoring.architecture snapshot carrying the migration records.
  • One-shot fx volleys no longer burn during the pipeline compile window (ledger 1200): while any fx pipeline was still building, the pass group skipped but stepEffect kept consuming spawn state — persistent beds (spawn.count) and burst volleys latched their fired flags CPU-side with no spawn pass ever running, so the work was overwritten unconsumed. Silent, never throws, permanent — and live-shaped: every effect in a joining client is created during exactly this window (the join snapshot arrives while the arena's pipelines compile). The fx spawn timeline now freezes until passes.ready() — elapsed, rate accumulators, burst timers, and persistent volleys don't advance while the pass group can't run; anchors keep refreshing so the first live frame spawns at the authored transform. Pinned by a permanent verify-fx-gpu-coupling scenario that runs a five-role brazier program verbatim through the compile window.
  • The engine's own god-mode effect decks shipped the wall-of-cards bug the fx skill teaches against: pre-streaked textures on align: "velocity" sprites (a velocity-aligned quad keeps its painted streak a constant ~67° off the motion axis on every particle — nothing decorrelates). All 12 velocity-aligned deck sites swap to round paints (rain spatter → effect-droplet-round-soft; sparks/magic/portal/embers/fireworks flares → effect-ember-glow) — motion supplies the streak. The kiln round-subject gate also recognizes explicit round tokens mid-name, so effect-droplet-round-soft-style mints hit the round gate instead of skipping it on head-noun matching.
  • The heightmap edit applier skips journal commands whose payload isn't a height edit: terrain-edit journals are kind-blind, shared per place, and survive terrain-kind flips by design — so after a tilemap → heightmap flip, a tile-set command's [[x,0,y],[x,0,y]] bbox was applied as a height clamp, pinning heightmap columns to height 0 (the corruption flowed into the chunk mesh, the heightfield colliders, and the horizon ring, which all sample through the same applyEdits composition). The tilemap and voxel appliers already filtered foreign kinds; the heightmap applier was the one hole of the three.
  • Pressing interact no longer fires an action's toast twice on setups where a key and a controller/touch gesture share a binding (ledger 1205). Each input source is edge-triggered per its own press edge, so one physical press could land the same logical interact in the interaction queue twice in one tick — and onInteract ran once per entry, doubling everything the hook emits (toast + announce on the staging zoo's exhibits). Interaction dispatch now coalesces duplicate queue entries per (source entity, target entity) within a tick. Legitimate distinct interacts are untouched: two different players pressing the same tick both land; only one player's duplicate sources collapse.
  • Cliff and steep terrain textures no longer smear on 5.2 (a porting bug inverted the side-projection axis): the biplanar side projection now samples the coordinate running across the face again — three-era parity — instead of the one along its normal, so carved cliffs, craters, and pond walls show their strata instead of vertically stretched streaks.
  • effect("blur") works again — the blur juice beat in death flashes and dashes was lost in the 5.2 renderer swap. The look resolver kept resolving blur, but the new renderer had nothing that consumed it, so the screen just stayed sharp (55 worlds call it). Blur now rides the look pass as a gaussian gather with the 5.1.x kernel's gaussian core (σ 9.5 texels per axis at full intensity; intensity 0..1 scales the radius exactly like before, and fading it never hitches) and a slightly tighter tail — support stops at 2σ instead of 5.1.x's 2.84σ, the disclosed tradeoff for a well-sampled single-pass gather. Scripted looks get blur(ctx.scene, amount) back in builtin/postfx (it re-samples the scene, like pixelate), and the skill teaches the verb again. Known boundary: blur composes with grades/vignette/grain, but stacking it with a discarding resampler (pixelate / chromatic aberration — the vhs, glitch, chromatic, speedLines, pixelate recipes carry one) silently drops the blur.
  • Large water sheets no longer draw over nearby transparent objects at certain camera angles: a transparent water surface used to sort by the signed view depth of its transform origin, so a big lake whose center sat behind the camera sorted as the nearest draw in the scene and stamped over bushes and foliage in front of it. Water now sorts by the farthest forward extent of its bounds (never negative), so shore objects composite on top of the water behind them at every angle.
  • Scripted materials reach models + decoration layers (the #8783/#8784 persist-gate parity fix): the shared zod schema now mirrors the engine's material types exactly. model.materials/parts accept ScriptedMaterialSpec (previously every taught per-part scripted write died at the kiln persist gate with properties.model: Invalid input), and DecorationLayerSchema gains its material field (zod stripped it silently on save — live-looks-right, reload-loses-it). A parity test pins both fields against the engine types.
  • Slash and shockwave effects no longer flash a black wedge from certain angles (ledger 1210, the staging-zoo juice aisle video). Every slash/shockwave spawned a vestigial invisible 1×1 plane alongside the real effect — a three-era leftover whose only remaining job was carrying a size the effect already reads from its transform. If that plane ever reached the renderer without its effect material tag, it drew as a plain untextured plane: black from its unlit side, the wedge in the video. The vestigial plane is gone; the effect draws from its own component and transform alone, so the wedge class is unrepresentable.
  • TAAU scrub fallback epsilon → f16 min normal (jure's find, ledger 1195 rider): the resolve's division guards used 1e-5, which sits below the f16 min normal (2^-14 ≈ 6.104e-5) — under the shader-f16 aliases the literal materializes as an f16 subnormal, WGSL permits flushing subnormals to zero, and an all-invalid 3×3 then computed 0/0 = NaN, re-seeding exactly the NaN class the scrub exists to contain. One untyped WGSL const (EPS_MIN_NORMAL_F16 = 6.103515625e-5, exactly 2^-14 — exact in f16 AND f32) replaces all three epsilon literals; a test pins the constant and sweeps every kernel axis for surviving flushable guards.
  • Fixed static objects visibly bouncing for some players while terrain was being edited live (ledger 1204). Terrain-anchored objects (feetPosition.y: { terrain: offset }) were re-resolved locally by EVERY client whenever the composed terrain height under them changed, while the place host's re-anchor writes also replicated in — two writers for one row. When two clients' terrain state briefly disagreed (live wisp edits, or plain join-time replication ordering), the writers fought and "static" props ping-ponged between the two grounds, differently on each player's screen. Re-anchor writes now belong to the entity's simulator only (owner/lease/place host); other clients receive positions over the normal replication lane. Cost: non-host players see anchored props settle one round-trip after a terrain edit — the standard latency class for host-simulated state. Singleplayer and server behavior unchanged. Toasts and announcements raised by behaviors no longer show twice for you in your own world. When a behavior fired a toast, the copy your game showed instantly and the copy the server reflected back were supposed to be recognized as the same event — but a toast with no icon/color set was recorded in one shape and came back over the wire in another, so the reflection slipped past the duplicate check and rendered again. The echo check now compares events the way the wire actually carries them, so behavior toasts, announcements, and every other behavior-fired effect land exactly once for the person whose game fired them (everyone else always saw them once). run_script toasts were never affected. Captures and Savi's live view no longer drop text and sprite objects that use motion/MRT rendering. On sessions whose scene pass carries the TAAU motion-vector target, some lanes' pipelines carry a second color attachment — and the capture pass declared only one, so any such pipeline handed to a capture faulted the whole submit with a GPU validation error ("[pipeline] is not compatible with [RenderPassEncoder capture/scene-view]") and the object went missing from screenshots, thumbnails, and view_live_scene. The capture pass now mirrors the live scene pass's attachment state (the motion vectors land in a scratch attachment that is discarded), so every live pipeline — text, sprites, and any future MRT lane — draws into captures unmodified. First captures also get slightly more complete: the capture reuses the live pipelines directly instead of waiting on capture-only twins to compile. When a script keeps using the wrong entity ids, Savi now gets told once clearly instead of the problem hiding in logs. The engine already taught the fix per call ("entity not found — call ignored. Use entity IDs, not array indices"), but a behavior looping over bad ids — sequential array indices, generated name suffixes — repeated that lesson thousands of times into the runtime log and nowhere else, so the game silently did nothing for hours while nobody noticed. Now a loop of misses from the same call site counts as one problem: after the tenth ignored call, Savi gets a single direct notification naming the object, the script, the ids it tried, and the fix, and the per-call warnings drop to a compact summary every 500 ignored calls. One-shot typos keep the full per-call teaching, exactly as before. A model told to play a clip that contains no motion now says so instead of silently freezing in a T-pose. Some GLB exports bake broken clips as a single static keyframe per track (a Blender/Mixamo action that never made it into the export as real animation) — the clip exists, binds, and "plays" its one bind-pose sample, so the model just stands frozen with zero errors anywhere, and the only way to find out was debugging clip-by-clip. Now the first time a script plays such a clip, Savi gets one clear diagnostic naming the clip and the entity and pointing the fix where it lives: the source file's export, not the script. Deliberate one-frame pose clips that are never played stay silent, auto-play behavior is unchanged, and the horde path reports the same truth instead of a misleading "clip not found".
  • World text stops shimmering/ghosting against its moving parent under TAAU (ledger 1222 — the "nameplate blurs when I run" pair). Text never wrote the velocity MRT (the alpha lanes' writeMask-0 default) and never writes depth, so the TAAU resolve's closest-depth dilation reprojected nameplate pixels with the OPAQUE surface behind them — a runner's nameplate rode the streaming terrain's motion while the body reprojected on its own, and the thin-feature lock preserved the stale glyph copies as a per-frame double image. The text lane is now the alpha bucket's cutout exception: instances carry the previous packed block pose (position + resolved billboard rotation, the primitive lanes' prevTransforms discipline), the MSDF fragment writes true per-glyph object motion via lume_velocity_pair, and the store runs one settle rebuild after motion stops so a resting text parks with zero velocity. First-time packs seed prev := current (zero object velocity on the spawn frame); hidden/refonted/dimension-flipped texts reseed instead of differencing across the gap.
  • Renderer scripted-texture library replaces now split changed-source refs from RETIRED refs (dropped from the library because nothing references them). Retired families keep their last-good texels and atlas grammar, leave the re-bake sweep, and never fire the "was not found" diagnostic — that teach is now reserved for refs never present in any received library (true typos). Previously a projection reset re-derived the library without retired scripts and the invalidation sweep re-baked every cached family variant into simultaneous false not-found reports (dig 20b7b07c: ~129 doomed bakes, one wasted Savi turn chasing a phantom).
  • The TextureScripts derivation dropped its carryover rule: the library is now a pure function of (spec + live draw state + live compiled fx programs) on every derivation path, instead of depending on session history (live tabs kept retired entries forever; post-reset re-derivations starved them). FX sink refs are now collected by walking FxCompiledProgram.
  • Client-reported engine.diagnostic rows are stamped with the reporting client's held spec revision + dbVersion (data.specRevision / data.specDbVersion), relay-stamped in the runtime worker — stale-client-vs-propagation questions become one read.
  • The variant-change room recycle in GameRoomRuntime.setSdkConfig now recycles the WHOLE room scope — host, SDK identity, and the room binding itself — and runs BEFORE bindRoomId (pc-42951396). The old order half-reset the scope: a different variant claiming the runtime tore the world down and reset identity but kept this.roomId, and the bind ran first — so an app switch that also moves the room id (local dev runs every app through one kernel container, and a live-mode session leaves the singleton runtime scoped to live-room-1 while the next game's dev room is room-1) died at the cross-room protection ([room-runtime] room mismatch: runtime is scoped to live-room-1, received room-1) instead of recycling. Same-room variant switches (the June #6757 behavior prod rides on kiln variant reassignment) are unchanged; the recycle no longer requires a live simulation host (a variant change with no host previously fell through to mergeSdkIdentity, which never clears fields — stale updateSlug/keys from the old game could leak into the new scope). The misroute protection is untouched: within ONE variant, a mismatched room id still throws — pinned in per-room-sdk-identity.test.ts, whose "stray rooms are refused" contract is now scoped to same-variant strays (the old pin's cross-variant stray was the app switch itself). On a cross-room recycle the new binding lands before onRoomReset fires (in-process/NO_WORKER relays the reset synchronously; an unbound runtime would be re-claimed by the old room's re-activation, first-bind-wins), so the old room parks "failed" and heals on its next real join, which carries SDK identity and lands back in the recycle.
  • The steady-state renderer-authoritative camera path now enforces the all-cameras-are-fully-authored ruling (ledger 1265, the 06-05 camera law): a mouse-reading custom camera classified onto the display-rate orientation path used to have its per-tick authored yaw/pitch writes structurally discarded — RendererCameraOrientation.updateConfig took "parameters only, never yaw/pitch" in steady state, so death-lock grips (the prod specimen: scripts write forceYaw/forcePitch, camera script snaps its yaw/pitch to the grip every tick) held rock-steady in sim state while the screen free-looked. Broken since the 5.0 cut; #8173 fixed only the non-lookAt sibling. The fix is the ruling's own named shape, a divergence-adoption gate at the config-ingest seam: config.yaw/pitch that is explainable as an ECHO of angles the orientation itself published (camera-sync mirrors them back through the f32 CameraAngles SAB, one-plus ticks stale, optionally advanced by the script's own look-axis integration of the same mouse motion) keeps renderer authority and display-rate mouse feel; anything else is the script authoring a different pose and it wins that same ingest — seed the divergent axes, publish so movement axes match the screen, drain pending deltas on full-pose snaps (zoom-handback semantics). The echo test keeps a per-ingest-interval envelope of published angles (tested per interval, so adoption discontinuities aren't convex-hulled over) extended directionally by the window's signed net mouse motion with 2× sensitivity headroom — a static grip behind a sweep exits immediately while a taught self-integrating orbit script never false-adopts. Per-axis adoption keeps display-rate look on the free axis under single-axis grips (horizon locks). The zoomTo sim-authority window and its handback seam are untouched.
  • The renderer→sim camera-angle mirror is now universal across live-path kinds (mirrorLiveCameraAngles, extracted from the custom-kind-only writeCustomCameraAnglesBack): built-in first/third-person and god sims learn the renderer's published yaw/pitch every tick, exactly like custom scripts. This is what makes the gate's invariant hold for channel-only look sources — gamepad sticks (writeGamepadLook) and touch drag-look feed ONLY the camera-orientation SAB with no lookX/lookY trace in the sim's input ring, so an unmirrored kind froze behind the renderer and the gate read its frozen state as a script override, re-adopting every ingest (measured pre-fix: intended 64.4° stick sweep → net 2.15°, view pinned). The mirror also dissolves two sibling symptoms on built-ins: lookX integrated during a zoomTo window no longer snaps at handback (the mirror re-bases through the zoom), and the pitch-convention rederive's flipped publish can no longer be adopted back against the correction. TomeCameraState is client-plane; no replication contact.
  • Known bounded residuals, pinned in tests and filed for the required sim-side authored-window latch follow-up: a player actively fighting a held grip sees wiggle-amplitude wobble (±0.83° moderate / ±4.1° hard at default sensitivity) or a one-ingest sawtooth under steady sweep (~1.7–5°), and a yank after wiggling around the grip gets up to the echo window (≈2 ingests, ~25° at hard-yank rates) of free runaway before re-adoption recovers the grip exactly. Mouse-still grips hold exactly.
  • The unattributed validation-storm death class (ledger 1232 — the 07-12 destroyed-scripted-texture storm: one validation error per submit, ~an hour of frozen screen with ZERO signal to the player or Savi) is now judged. Validation errors that match no rail (not the dead-device regex, not the #187 burst classes, no scripted-material label to park) used to fall through handleDeviceError as a per-frame silent no-op; they now feed a sustained-storm window (≥24 errors spanning ≥15s inside 30s — reachable in both delivery regimes, including Dawn-muzzled sessions where errors only arrive via the #365 sentinel probe) that rides the SAME ladder the known burst classes use: bounded render-chain rebuilds first, then the honest device-lost declaration. No new walls, no new transports — the trip lands on the existing #6710 sticky reload wall for the player and the existing renderer-device-lost engine-diagnostic (getLogs + one deduped DM) for Savi.
  • The renderer-device-lost diagnostic message now carries the client truth instead of a mechanism note: the player's screen is frozen on its last frame (or black) and shows nothing until they reload, suggest refreshing if they mention a stuck screen, this is a device/renderer failure — not a game bug — do not rewrite or optimize content over it, and view_live_scene may not reflect what that player actually sees. Informational, never a work order (the savi-perf-notifs-pull-not-push law). Once per death, content-deduped server-side, zero cost when healthy.
  • Discard-carrying opaque draws now render on their own cutout channel AFTER plain opaque (#9055). Punch-through pipelines (fragment discard) disable early-Z and TBDR hidden-surface culling for whatever is still undrawn behind them, so mixing them through the opaque bucket in registration order wasted the depth buffer's rejection power — worst on mobile tile GPUs. MainPassDraws gains a cutout channel and collectMainDraws folds it in after every contributor's plain opaque (registration order within each class; return shape unchanged — chain and capture consumers untouched). Routed emitters: model MASK slots (static/horde/character), decoration sprite + scripted layers, the opaque voxel bucket, rooms submeshes, built-in tilemap draws, and all four scripted paths (tilemaps, models, character primitives, standalone primitives route by shade.alphaTest > 0). Both channels depth-test and depth-write, so the split is pure ordering, never correctness. Shadow passes untouched (every lume shadow caster is vertex-only depth-only — no discard exists there, already optimal).
  • Made the destroy/name-resolution funnel honest (ledger 1247, dump a693ca36 — Savi aimed every kill at "5", a list POSITION, while the target was the object named "garden-music"; every wrong kill read as success). Three seams, no new vocabulary: (1) destroy(id) on an id that matches nothing in the live world AND nothing in the spec now reports on the missing-target rail (warnMissingMutationTarget, the ledger-1215 escalation funnel the other mutation verbs already ride) — visible in getLogs and in-band in run_script results; spec-declared ids stay quiet (client-realm local spawns and unmaterialized places are delivered by the clientDestroy fan-out, and double-destroy of a real object stays idempotent-silent). A bare-number miss that lands inside the caller's place objects array names the object sitting at that position ("the object at places.main.objects[5] is "garden-music"") — the error text is the teaching. (2) getSpec numeric path segments that land on an id-carrying object still resolve (positional reads keep working — nothing in-tree relies on them, but shipped worlds might) and now say which id the position landed on, once per digit-folded path shape per cooldown. Prefer ids in spec paths; the positional form on id-carrying arrays is deprecated in spirit and a candidate for removal at the next breaking window. (3) api.query gains "enumerate" visibility: with no radius, camera-attached objects match like any others (the enumerate-everything fallback was structurally blind to them); with a radius they stay excluded (their WorldFeetPosition is a bookkeeping anchor, not a place) but the exclusion is reported, naming the hidden ids. nearest() and every engine caller keep pre-1247 "sensor" visibility — byte-identical behavior.
  • The stale host-epoch wedge (ledger 1257 — jacob's Waystead carried epoch 48 into a place re-epoched to 2 for an entire session; jo's Sunhollow Farm dropped 1,238 host writes on a room restart and half-built a farmhouse) now self-heals server-side. The epoch gate itself is untouched — host writes stamped with a dead epoch still drop exactly as before — but the state-delta drain now tracks continuous stale-epoch drop windows per sender, and when the skipping is provably a wedge (≥30 dropped-write messages spanning ≥10s, uninterrupted by a table-corroborated stamp) it flips the sender's projection to NeedsReset: the next egress tick ships the production reset snapshot (current TomePlaceHosts included) and the client rebases through the rail every join/migration/resume already uses. The thresholds sit an order of magnitude past the legitimate migration straggler window (~1 RTT of in-flight messages), a ≥30s quiet gap restarts the window so separate migration races never accumulate, a valid host stamp clears it, and a 60s per-client cooldown bounds re-forcing when a client is reset-proof wedged.
  • Telemetry: each force emits one DD-countable netcode.state_delta.reseat_forced warn (client, place, epochs, skip counts, forcedCount), one script-visible runtime-log line for Savi ("the server re-seats the client automatically — no rejoin or tab-close needed", so she stops prescribing the close-and-rejoin remedy that triggers a host migration and drops persist:false entities), and a reseatsForced counter on the upload stats (F3 net uploads line).
  • Field-chunk change tokens are content-aware — the version-ABA at the shared invalidation seam is dead (ledger 1250). applySerializedFieldToWorld pins every restored chunk to version 1 (and a recreated chunk restarts at 1), so a spec-level rewrite of an already-painted chunk (updatePlace/definePlace field patches, brush gesture-cancel, hydration of changed fields) landed v1→v1 with DIFFERENT cells and every version-keyed consumer kept stale state until an unrelated input moved. commitFieldChunk — the single seam every local chunk write funnels through — now stamps cellsHash (FNV-1a over the int16 cells mixed with the quant/cellSize they decode under, ~4.6µs per default 4096-cell chunk, on a path already O(cells)), and every change-token consumer mixes it. All seven, by name: the terrain mesh inputs-hash + horizon + spline-redrape (via getComposedFieldChunkVersionsInBounds/composedChunkChangeToken), nav's composedFieldPlaceSignature (#9019), terrain-anchor re-resolution (composedFieldVersionAt), the scatter footprint memo (bed-field layer signatures now read chunkChangeToken off live chunk components AND the terrain:height walk), the scatter-instances supertile signatures, and the decoration-density layer snapshots. Versions keep their ordering role (resim reset, predecessor+1 bumps) untouched — materialized per-layer FieldData.chunkVersions deliberately stay RAW versions because translate/write derive version + 1 from them; per-layer signature consumers read chunkChangeToken off the chunk components instead.
  • Chosen over never-restarting version seeding deliberately: the hash is a pure function of the replicated chunk value — identical on client and server for the same write, stable across resim replays (a counter/timestamp scheme would diverge between realms and churn under resim, and the rollback fast path applies chunk values without set/add events, so hook-fed counters can't be trusted either — the reason field-feed-derive's destroy-epoch shield had to exist; that shield stays and keeps covering the feed + GPU field-texture path). Bonus: identical restores are byte-quiet — a spec re-apply of unchanged fields rebuilds nothing.
  • The composed token also closes a max-masking hole: max(authored, runtime) hid a write to the lower-versioned layer under the higher one; the token now mixes both layers independently. Mixing quant/cellSize into the stamp closes a second pre-existing blindness: identical raw cells restored under a different grid decode to different world values, invisible to version equality.
  • TomeFieldChunkValue.cellsHash rides the existing JSON encode/decode transparently; values from pre-hash builds read as 0 and re-stamp on their next commit.
  • The quality governor recognizes the interval-capped regime and stops paying quality for it (ledger 1223 — the 60Hz sibling of the L19 pacing-first class). When a browser environmentally caps rAF delivery at a mixed cadence (the iPad CriOS specimen: ~35Hz arrivals on cpu 2.4ms + gpu 7.1ms of measured work), the fused wall signal read sustained overload, the ladder walked to the survival floor in ~40s, and five rungs of cuts moved the interval 32.1→28.4ms (~12% — the cuts bought nothing), then held the floor forever because calm was unreachable. The governor now keeps a relief ledger per overload down-step (predicted relief = the same live/applicable/relieving costHints the probe gate charges) and classifies the regime INTERVAL-CAPPED when consecutive steps verifiably under-deliver while the measured work explains neither the budget nor the interval — then restores the pre-descent rung in one shift (interval-cap-restore, on the transition ring and telemetry) and holds the same wall-interval gate the clean-2× environmental signature gets (the capped cadence stops entering the fused signal; a genuinely degraded ≥40ms interval still does), releasing when arrivals return to the judged display rate. The exact-2× environmental signature is untouched — this covers the mixed-cadence space it deliberately misses. Real overload is unaffected: measured work fails leg (a), and a genuinely GPU-pinned device's interval shrinks when pixel work is cut, so relief verification passes and the descent proceeds.
  • The interval-capped latch (ledger 1223, #8971) finally gets SAID instead of silently absorbing the blame (two specimens in two days: the iPad CriOS ~35Hz session and a MacBook at 12fps under Low Power Mode all session, found only by luck). On latch engage: (1) PLAYER — one toast per session on the existing juice toast surface, watched at the renderer→host perf-sample relay (no new transport), mechanism-honest wording ("Your device is limiting frame rate — Low Power Mode or battery saver can cause this"; the engine detects the CAP, never claims LPM itself), never repeated on re-latch, transient by construction; (2) SAVI — one render-interval-capped diagnostic per session riding the existing engine-diagnostic rail into the perf-notify pointer DM (new render-interval-cap category: environment-not-content blame, consent-framed, on the same global 1/hr budget — any perf DM silences it for the hour), with the full verified-cap body in the runtime log for the pull side. The client-health snapshot now carries intervalCapped (strict-boolean validated) and the getClientHealth summary names the cap at any rung — including restored-to-full, where the rung-0 fast path used to hide it. The governor's classification logic is untouched; this consumes its latch state.
  • Fixed the transparent-lane sort flicker class (ledger 1242, Space Craft protocell): the alpha bucket sorts translucent primitive lanes as whole batches by a centroid-anchored sortDepth, so nested translucent lanes sharing ~one world center (base shell lane + emissive-split lane + inner cubes at the same point) tie within noise — per-frame centroid drift (membership rebuilds re-snapshot displaced instance transforms; camera motion moves every distance) flipped the sign of the tie and the lane draw order flipped frame to frame, z-fighting whole translucent layers. Regressed 5.1.13 → 5.2.0 with the lume whole-batch alpha sort. Fix is a deterministic near-tie order, never a new depth model: PrimitiveLane mints a creation ordinal (alphaSortTiebreak), the lane and oversized-pool alpha draws carry it as SortableDraw.sortTiebreak, the alpha sort itself stays a consistent pure-depth comparator (an epsilon comparator is intransitive — spec-level implementation-defined sorting), and a post-pass (stabilizeAlphaTieRuns, sort-order.ts) then reorders each maximal run of consecutive carriers whose neighbor gaps are within ALPHA_SORT_TIE_EPSILON (0.25 m: comfortably above cm-scale centroid drift, far below deliberate authored separations) by ordinal — ascending, later-created lanes composite on top; deterministic and input-order-independent, and a coincident pair's mutual order is pair-local (no third lane can split their run). Sub-epsilon ladders deliberately chain-merge into one ordinal-ordered run (documented trade against already-approximate whole-batch centroid depths); gaps beyond the epsilon never form runs, so genuine separations keep pure depth order. Draws without a tiebreak (2D sprite sort bands, per-entity scripted/model depths, particles, water) never move and break runs — bit-for-bit pure back-to-front — and a lane's emitted back/front two-pass pair order rides the run sort's spec-guaranteed stability (equal depth, equal ordinal).
  • Fixed dead mod HUDs after install (ledger 1271): mod install namespaces the mod's inputs (edgeX → rts-controls:edgeX) and the input proxy resolves the mod SCRIPT's bare reads to the namespaced form, but a mod HUD wired at ui.render sent BARE sendAction('select')/sendAxis('edgeX') — undeclared, dropped, dead buttons. The {modName}: send-prefix rewrite existed only for mod-installed creator tabs. The compiler now stamps uiModName on the compiled spec when ui.render points at an installed mod's script (a mods/{name}/… ref with {name} present in spec.mods), and the game UI surface carries it as actionPrefix/axisPrefix through the existing dom-host maybePrefixInputName — the same mechanism creator tabs use, no new spec state. input.actionData reads get the same mod-scope resolution the action/axis proxies already had, so payloads sent under the namespaced action land on the mod script's bare input.actionData.select. Creator-authored ui.render never matches (no mods/ prefix or no manifest entry) and stays byte-unchanged; uninstall deletes the spec.mods entry, so the prefix comes off with the mod — the fix writes nothing into the spec, leaving operation-precise uninstall untouched.
  • The NPC nav floor oracle now reads sculpted terrain (ledger 1244 — jesse's Field Orders: units passed THROUGH god-mode-sculpted hills at y=0). NavGrid's heightmap column sampler composed only heightFromDefinition (generator + marks) and never consulted the replicated terrain:height field overlay, so navAt answered floorY 0 / blocked:false on field-raised ground and movers kept agents at the generator height. The sampler now composes the same ground the terrain mesh/collider build bakes — generator + marks + composed field overlay — through the same pipeline (getComposedFieldInBounds materialized once per nav chunk, readFieldValue bilinear per column), gated to heightmap generators exactly like the mesh (fields sculpt heightmap terrain only); every other generator derives byte-identical columns.
  • Field writes now invalidate the nav grid: the per-place input compare gains a terrain:height place signature (new composedFieldPlaceSignature in field-store — an order-independent fingerprint over painted chunk coords/layers/versions, both layers). It is a query-time walk refreshed at tick boundaries, never a component subscription, because the prediction rollback fast path applies field-chunk values without firing set/add subscribers (the field-feed-derive rule) — so sculpt, erase (chunk despawn), and rollback-applied field state all rebuild nav on every realm. Unpainted fields cost two index probes per place per tick.
  • Perf (36-chunk / 9216-column region rebuild, shared dev box): generator-only 5.2ms before → 5.6–6.8ms after (within the 4.2–5.2ms noise band measured at HEAD); field-painted 4.2ms (overlay ignored) → 8.0ms (+105µs per lazily-rebuilt chunk); steady-state refresh with a 64-chunk painted field 3.7µs → ~9–13µs per tick across 20 getNavGrid calls.
  • Fixed the unanchored-pushLook permanent library miss (ledger 1241): a look script referenced ONLY by api.pushLook(scriptRef) — never anchored to any place's atmosphere.look — has exactly one lane into the renderer's LookScripts library: ensureLookScriptEntry at push time (syncLookScriptsWithSpec collects refs only from atmospheres, live overrides, and existing library keys — pushed layers are invisible to it). That ensure's component write can be lost while the layer itself survives (behaviors resim in prediction overlays that stage-and-discard component writes but leak the in-place LookLayersResource Map mutation; projection resets re-derive the library without pushed refs) — after which nothing ever repairs the miss: the renderer parks the look on scripted-look-compile-failed claiming the script "was not found in this game's scripts" with the file present in the spec, under either ref shape (scripts/look-x.js or look-x.js). The specimen (Primordia, three sessions) only escaped by anchoring the script to a place's atmosphere.look (v1761), which routes the ref through the spec-sync collector — an undocumented requirement pushLook was designed not to have. Two repair lanes now converge it, mirroring the scripted-material twin: the look resolver (client renderPrep, base world — writes stick) re-ensures a resolved ref whenever the library misses it, healing in the same frame the resolved look ships (healthy frames cost one component read + four map probes); and spec sync now collects live pushed-layer refs exactly like collectLiveMaterialRefs, so every spec application repairs lost entries. Unanchored pushLook now just works. LookLayersResource (+ ActiveLookLayer/LookLayerState) moved from systems/look-resolver.ts to resources.ts to keep look-scripts ↔ look-resolver acyclic. The compile-failed DM now only fires for scripts genuinely absent from the spec, and its fix-text names both look entry points (atmosphere.look.script and the api.pushLook arg).
  • Shade compat: unknown vocabulary reads through TSL no longer throw — they return undefined with a one-time teaching warn (shader-vocabulary-unknown-symbol via getLogs), so a script written against newer vocabulary degrades instead of killing the material. Adds varying and fwidth to the shade vocabulary.
  • The delta-0 short-circuit's completed-apply binding now survives coherent ObjectAPI persist mirrors (ledger 1268, PR-C #7919 follow-through). The binding veto (canShortCircuitIdenticalApply: generation record's specRef must be the exact live GameSpecResource doc) existed to prove "the world already IS this spec" — but every persist-mirrored write (persistent spawn/destroy, setProperty/updateObjectSpec persist) moved the doc head and permanently disqualified the game from the 2ms identical-apply lane, even though those mirrors write the live ECS effect and the doc in one synchronous op (the instant contract). Prod receipt: a zero-delta apply measured 5,233ms (shortCircuit false, objectsVisited 0, objectsSkipped 2050, expandPasses 2) on a live game whose applies cost 2.2ms whenever the gate binds. Now updateSpecResource advances specRef alongside the head for writers that declare worldCoherent — the persist mirrors, gated per write by isLiveCoherentPropertyKey: every persisted key must have a live runtime writer (the NO-WRITER schema keys room/collider2d are spec-valid but APPLY-DERIVED — expandRoomObjects / physics-primitive derivation materialize them only in a full pass — so their mirrors keep breaking the binding) and must not be a top-level key (behavior/parent/tags defer wiring to a requested reconcile). Spawn mirrors break the binding when the def carries place-level expansion content (room/spline). Writers that request an applySpec reconcile (updateSpecAndRequestApply: setScript, patchTerrain/Player/Camera, add/removeBehavior, definePlace/updatePlace) keep breaking the binding, so a drain of their content can never short-circuit past its own recompile/rebind — op-count-pinned in both directions in spec-apply-changed-set.test.ts, including the no-writer-key negative pin and a mixed-order burst pin (once broken, later coherent writes cannot resurrect the binding until a full apply rebinds). Note: patchState/replaceState write live TomeState only (no spec mirror), so pure state writers never touched this binding in either direction — the storm lane this fixes is the property/spawn persist mirrors.
  • room-runtime's applyTomeSpec now recognizes the kiln poke-back echo: a fetch at a strictly-newer dbVersion whose content the delta-0 gate certifies as the fully-applied live generation (the room's own persist echoing back through a version the consecutive-only noteAuthoredDbVersion stamp couldn't absorb — interleaved persists) still runs applySpec (its internal short-circuit costs ~2ms and keeps the rails a real short-circuited apply owes the world: the spline-orphan sweep and the tome.apply.episode telemetry), but suppresses the tail — the revision-bumping upsert and its replicated push fan-out to every client — stamping the fetched dbVersion so the staleness guard sees the room at the echoed version. The gate is wouldShortCircuitIdenticalApply — the apply's own predicate, run conservatively on the raw doc. Replace pokes never dedupe (replace re-asserts live state, ledger #296); retry states (degraded lowering, compile errors, scatter recovery, residency growth) fail the predicate and take the normal tail; a failed apply falls through to the raw-reference upsert retry rail.
  • Deliberately NOT in this change, named for the ledger: (a) the O(spec) skeleton phases (expandPlaces ×2, normalize/namespace ×2, signature rebuilds) still run whole-doc when a real delta arrives — making them O(delta) needs identity-threading through sanitize/namespace/normalize plus expansion caching with world-state (terrain/residency) validity, a separate careful change; (b) the multiplayer-relay fold lane (foldSpecMutationsIntoWorld) still breaks the binding on every forwarded batch; (c) mid-apply mirror writes (self-rebuilding creator behaviors, the vil-home-builder bench residual) still land the generation record behind the head — the binding re-forms at the next quiescent apply instead of never.
  • 2D pixel-sprite aliasing/shimmer fixed at the sampler math (jacob's batch-2 felt report, 2026-07-12 — sprites in his 2D sessions crawl/shimmer under camera motion; same session family as the #8988 tilemap seams, whose screenshot pinned the zoom at ~3.65 screen px per texel). Root cause: pixel-grammar sprites sample NEAREST with no mips, and at fractional magnification the art's texel boundaries land at fractional screen positions — texel columns alternate ⌊3.65⌋=3 and 4 px wide as a function of the quad's subpixel phase, and every camera pan (or sprite move) rephases the pattern, so texel edges flip full-contrast frame to frame. Camera pixelSnap cannot cure this class: it quantizes the presented camera to the SCREEN-pixel grid, but the texel:pixel ratio stays fractional. Fix: pixel-art texel AA in the lume sprite fragment (material.texelSnap, per-batch) — the batch binds a LINEAR clamp sampler in place of the lease's nearest one, and the shader remaps each sample to its texel center (bilinear weight 1.0 — bit-identical texels) except inside a one-screen-pixel band at texel seams, where the linear tap interpolates (seam + clamp((t−seam)/fwidth(t), ±0.5)). Texel interiors stay bit-crisp, seams render at a stable subpixel position instead of re-picking texels, and the mapping is bit-identical to nearest at aligned integer zoom (pinned by f32-emulated test). The flag mirrors the asset service's sampler policy exactly via spriteTexelSnap in sprite-logic — precedence: repeatX strips never snap (they lease wrapS repeat for the −1..2 U span; the clamp AA sampler would stomp tiling — mechanical wrap need, doctrine-aligned with the plate behaviors), then authored sprite.filter both ways, then the atlas meta's manifest-authored filter both ways (explicit filter: "linear" on a grammar-pixel id is never snapped), then the texture-id grammar (plates/tiling surfaces stay linear). Linear-filtered sprites, placeholders, outline taps, and the tilemap lane are untouched.
  • A null entry in terrain.marks no longer kills the server (DD sweep 01b440e6 — 396 rows in one hour, all app a6af7bb9, TypeError: null is not an object (evaluating 'mark.kind') in isHeightmapTerrainMark, emitted as both tome.terrain.anchor_refresh_failed and tome.reconcile.spawn_update_failed, cascading to 3× process.uncaughtException and [FATAL] System "tome/physics-dispatch" permanently failed after 5 retries — server restart required). Two halves, per the savi-failure-contract (a failing spec degrades exactly its own object, never a server restart):
    • Write side (root): mergeSpecPatch's replacement branch now recurses plain-object patches with an empty base, so merge-patch null-means-delete holds at EVERY depth. Before, updatePlace("main", { terrain: { marks: { old: null } } }) on a place whose terrain had no marks key stored { old: null } verbatim into the spec — a persistent poison that crashed every subsequent apply and tick. A null inside a fresh subtree deletes nothing, and nothing is what it stores now. Uniform across every merge-patch surface (updatePlace, patchPlayer/Camera/Engine, buyables, terrain materials/structures), not a marks special case.
    • Read side (seatbelt, covers already-poisoned persisted specs): the terrain family guards (isHeightmapTerrainMarkMap/isVoxelTerrainMarkMap) skip null/non-object mark entries instead of crashing inside their .every(), with one deduped [terrain/marks] diagnostic per map instance naming the offending keys — matching the skip every downstream mark consumer already does. normalizeVoxelMarks (the one other unguarded mark.kind iteration) gets the same skip; reportInvalidTerrainMarks already names the entries on terrain-content change.
    • Cascade pin in physics-dispatch.test.ts: a marks map containing null → system tick completes, diagnostic emitted, valid marks keep dispatching liquid hooks, no throw. Family-guard + resolve pins in marks-null-entries.test.ts; write-side merge-semantics pins in spec-write-contract.test.ts.
  • Texture load failures reach Savi again (ledger 1264, savi-failure-contract gap — prod specimen [LumeTextureService] Failed to load texture scripts/tex-ocean.js: ctx.canvas(64, 2048) exceeds the 1024×1024 cap, fired 3× client-side while Savi shipped the broken visual). The ledger-895 retry-exhaustion emitter for texture-load-failed died in the lume port: LumeTextureService's generic retry ladder ate every non-policy failure (bake transport rejections, GPU upload throws, transient-looking fetch errors) with a client-console console.error per attempt and then looped on a long cooldown forever — no runtime-log entry, no DM, nothing Savi could see. recordRetryFailure now fires reportEngineDiagnostic('texture-load-failed') exactly once per failure episode — guarded by a reported flag on RetryState (the terminal pattern), not an exact attempts-crossing check, because the policy rail increments the same counter and a mixed budget/generic interleave could otherwise skip or re-open the crossing (review 9054); success and source edits reset the episode — carrying the underlying error text and, for scripts/*.js refs, the edit-re-bakes recovery. Degradation is unchanged — entities keep their last good image or loading placeholder and the ladder keeps retrying; this only ADDS the feedback path. The bake-fault policy taxonomy (texture-script-compile-failed/-runtime-error/-budget) already reported on park and is untouched. Contract row + harness added: the cap specimen drives the real service to exhaustion and asserts one framed getLogs entry + one DM across three reporting clients (service.test.ts), per the savi-failure-contract harness pattern.
  • run_script forward-timeout copy stops teaching a false trigger model (look 1b53cc7e, same creator session): the error text said "Run it again once the game is back in view" (Savi cannot observe tab visibility and cannot run without a turn — she repeated it to the creator as a promise) and "with the script still queued" (no queue exists; the script is discarded). Both timeout reasons in script-forward.ts now state the truth: nothing is queued, the change has NOT been made, and if the creator asked for the change, tell them plainly it hasn't happened yet. Retry guidance is branch-honest: in singleplayer, retry is only possible in a turn while the creator's game tab is visible (ask them to keep the game on screen and ping); in the left-SP-mid-flight branch the room is multiplayer at timeout, so a retry runs on the server directly and the copy says so — no spurious visibility precondition (review 9054 truth-tighten). Error text is API surface; pins extended in script-forward.test.ts.
  • Tile edits gain the client→server lane they never had (ledger 1248 — Jacob's Waystead garden, dig 4287dc11). In client-auth rooms, api.setTile/clearTile/resetTile called applyTilemapTileOverride bare while voxel edits rode tome/voxel-edit-rail, so gameplay-script tile edits on a client lived in local chunk state only, forever — every server-driven chunk reconcile (host-epoch NeedsReset rebase, rejoin, AOI chunk rows) wiped them. The rail now carries a tile kind alongside set|fill|stamp: the ObjectAPI tile verbs route through applyTilemapTileOverrideFromScript, which applies locally first (responsive feel, byte-identical outside client-auth) and forwards over rail.voxelEdit under the exact voxel gates (active script ref, owned/lease/host/interaction-grant source, no transactional overlays, no singleplayer). The server half validates shape + tile id range, charges the same shared per-tick voxel-rail work budget (one cell per edit), and re-applies through the same funnel — so terrain-edit persistence and AOI fan-out ride the existing rails and the reconcile RETURNS the edits instead of wiping them.
  • Provenance parity with the voxel kinds: applyTilemapTileOverride gains an optional authorId stamped on the stored edit command (script paths stamp the executing source entity; the god-mode tile brush's replicated authoring-input path stays unstamped and on its own lane — it never touches the rail, so no double-apply).
  • Version-skew semantics (one kernel manifest ships client + server atomically, so a 5.1.x/5.2.x pin sees neither half and behaves byte-identically to today): a newer client against an older server gets cmd.err bad_payload for the unknown kind — the local apply already happened, degrading exactly to today's local-only behavior; an older client against a newer server sends no tile forwards and nothing changes.
  • 2D tilemap fractional-zoom seam lines fixed at the sampler math (jacob's dirt field, #inventors 2026-07-12: 1px grass-green vertical columns every 175 screen px through a dirt tilemap — measured in the screenshot as neighbor-tile-colored, exactly every 3rd tile boundary at a 175/3 px on-screen tile). Root cause: the built-in tilemap shader's corner UVs land EXACTLY on the atlas cell boundary (tilemapTileUvRect rects + corner uv 0/1, no gutter), so an edge pixel whose interpolated tileUv rounds onto — or, under MSAA center-eval on partially covered edge pixels, past — that boundary makes the nearest-sampled floor(u·W) read the ADJACENT atlas cell's first texel column; the quad-edge/pixel-grid subphase repeats every few tile columns at rational fractional zooms, which is why the wrong-texel column recurs periodically and only "at exactly the right zoom". Fix: the fragment clamps tileUv to the per-instance cell rect inset by half a texel (rect rides a new @interpolate(flat) varying; half-texel from textureDimensions) before the V-flip and both asset taps (tileset + lit normal companion). The inset is identity for every in-cell pixel under nearest sampling (the clamp bound is the edge texel's center — no art rescale or shift, pinned by test) and is the standard gutterless-atlas containment under linear. Square and diamond variants both carry it; the cooking wash keeps raw tileUv. Same-class exposure existed in the three-era material (uv().mul(uvRect.zw).add(uvRect.xy), no inset) — long-standing, not a lume regression.
  • Fixed the zombie-host class in client-auth rooms (ledger 1237): place-host assignment elected ANY Ready client, so a non-simulating instrument client (the thin-client stress harness — it ignores host.assign controls and simulates nothing) alone in a room became the place host forever: healthy input cadence kept it "alive", nothing ever simulated, and run_script/server-write forwards died in fence timeouts — strictly worse than an empty room. Fix is a host-eligibility opt-out riding the existing join payload, never a new authority path: a client appends ?hostEligible=false to the gameplay socket URL, the network worker carries it on the existing connection.attach ingress message, and isEligibleConnection (shared by place-host assignment AND the sim-lease reconcile sweep) requires hostEligible !== false. Absent field = eligible, so old clients and pinned engines are byte-for-byte unaffected; every attach adopts the NEW socket's declaration, so a later session on the same clientId never inherits a predecessor's opt-out. A room whose only occupants are ineligible behaves exactly like an empty room today: unhosted (frozen, not server-simulated), server-originated writes final at the canonical copy (no forwards, no fence timeouts), re-hosted at a bumped epoch by the next eligible entrant. The thin-client stress harness now declares the opt-out on every socket URL. Migration/epoch machinery untouched.
  • Static mesh colliders map again for schemeless cdn/… asset paths (the 5.2.1-only zoo-statics regression: playground trees/rock/swap-car collided as placeholders for the whole session with [mantle] static collider failed to map spam, A/B-isolated against 5.2.0). Two halves in the collider-asset lane (physics/systems/collider-assets.ts):
    • URL half (root): the lightweight extractor lane now roots schemeless relative CDN paths (cdn/model-x.glb → /cdn/model-x.glb, the asset service's own normalizeAssetUrl semantics) before fetching. isMagicCdnUrl accepts the schemeless form (it URL-parses against a base), but the fetch used the string verbatim — in the browser runtime worker that resolves against the client bundle directory (/v4/client/<hash>/cdn/…), a deterministic 404 for every such asset. The lane had been silently rescued by the THREE.js fallback since it shipped.
    • Classifier half (what 5.2.1 broke): #8936's verdict classification skipped the THREE.js fallback for EVERY MagicCdnFetchError, but only a classified verdict — 202 still-generating, or a server-stated window (429 quota / tombstoned generation, retryAfterMs) — is an answer about the ASSET that the fallback would just re-receive. The skip is now scoped to exactly those classes; an unclassified failure (bare 404/5xx, no verdict window) falls back to the asset-service lane again, which resolves URLs independently and holds its own cache + retry ladder. The ledger-1025 park contracts (still-generating ladder, verdict windows) are untouched and pinned.
  • Rapier 3D rotation readback preserves quaternion w = 0 (#9096): readBodyRotation coerced w with toFiniteNumber(raw.w) || 1, so the legitimate w = 0 of any exact-180° rotation read back as w = 1 and re-normalized into a wrong orientation; the fallback-to-1 now applies only when w is non-finite.

KNOWN OPEN AT FOLD (5.2.1 staging draft, 2026-07-13 — named honestly for the walk and the release packet; the draft:false flip is the train's separate human-gated step):

  • Houses / transform-fighting cluster — RELEASE GATE, BLOCKED: multi-part assemblies oscillate between two poses on master (the 07-13 houses briefing). Receipts: two transform families are both replicated AND forwarded, and lume reads the local family via the presentation smoother while world-family forwards exist — two writers fighting at the presentation seam. Sim state is correct per tucker; renderer-read side, not physics. The 5.2.1 released flip is blocked pending jure/tucker's local session.
  • 2.5D sprite TAA blur: motion vectors are absent for sprites under TAAU, so moving sprites smear/ghost under the temporal resolve. Jure's ruling: known design gap, its own thread — P0 per tiger. (World text landed its velocity writes in this draft; the sprite lane remains.)
  • Text motion vectors: the remaining text surfaces on the same MRT velocity lane — plan p-0ea8336e in flight.
  • Flat-world horizon: the deck streaming radius ends before vision does, so flat worlds show a content edge at the far horizon (ledger 1276) — a design item, not a regression.
  • Audio clip quota cap now parks gracefully and recovers when clips free up, with a Savi-visible budget diagnostic (Jul-13 speech findings, #9087).
  • 10x decoration deck limits on desktop (Jacob's ruling, #inventors 2026-07-13: "fuck it 10x the upper limit. make sure rungs degrade so we don't blow mobile memory limits"): the desktop tier baseline carries decorationBudgetScale/decorationDistanceScale 10 (geometry-budget.ts) and the desktop scatter admission ceiling rises 200k → 2M instances (perf-static-data.ts SCATTER_BUILD_INSTANCE_BUDGETS) — the default deck builds 2,000,000 instances reaching 1,120 m (was 196,775 @ 112 m skirt). Measured basis: the 07-13 flat-meadow L4 ladder (real GPU timestamps, eye-height worst-case view) — 10x is GPU-time-flat vs 1x (far rings thin under the LOD keep, blade screen size collapses); the honest cost is instance-buffer VRAM (~200 MB at ~100 B/instance — instances 80 B + survivors 4 B + bounds 16 B — for the default deck). Tablet/phone tiers hold their shipped scales and ceilings (unmeasured silicon — the ruling's named mobile-memory hazard), and the ladder's geometry cuts compose multiplicatively on the new baseline (10 × 0.5 / 10 × 0.25), rebuilding the deck smaller so instance VRAM sheds with the rung. NEUTRAL_GEOMETRY_SCALES is now its own identity object instead of aliasing the desktop baseline (composing a baseline with identity must not square the 10s); on desktop the rung-0 admission clamp is now the routine deck shape (every shipped deck lands exactly at the 2M ceiling), not a pathology signal.
  • Rim height ramp for the decoration deck (jacob's occlusion-by-height pick, #inventors 2026-07-13): ground-cover cards (grass-kind + card sprites ≤ 1.5 m — the same class that defaults to the 80 m card far) grow height-only from 1.0× to DECOR_RIM_RAMP_MAX (1.75×) over the last 30% (DECOR_RIM_RAMP_START 0.7) of the item's own draw distance, holding max through the #8691 far-skirt fade — so at eye height the taller rim occludes the bare terrain band beyond the streaming radius on flat worlds instead of the field ending flush with the ground. The sampler carries the item's far in the header's spare float (countPad.w, 0 = off) and scales only the instance matrix's up column, so width/wind/tint and the fade's own scale-to-zero are untouched; the ramp is a pure function of placement distance (deterministic per seed), placement and counts are byte-identical (zero new instances), and per-entry cull spheres grow by the ramp max so tall rim blades never pop at frustum edges. Tall cards and primitives keep their authored silhouette (rimRampFar 0).
  • World text billboards now resolve current and previous camera-facing poses in the vertex shader, eliminating camera-driven instance-buffer repacks while retaining entity-motion vectors. The TAAU path splits fully opaque glyph interiors from the blended antialiasing fringe, keeps distance fading continuous, preserves motion history across appearance-only edits, and uses the renderer's cutout ordering for discard-heavy cores. Scene-view captures evaluate the fade at their final output resolution.
  • text and sign.text now support the sprite-compatible sortingLayer and sortingOrder fields in 2D scenes. Explicitly layered text joins the sprite depth band; unlayered and mounted text keeps physical depth. Orthographic pixel snapping now reaches live, synthetic-harness, and recorded-replay text consistently.
  • Reverted the steady-state camera divergence-adoption gate (#9059, squash 5a2d7822; ledger 1265). The gate false-adopts on self-integrating authored camera rigs that keep private angle state: a script that integrates its own yaw/pitch at its own sensitivity from its own base and publishes once per update runs a second integrator against the renderer's display-rate one — two integrators at different gains diverge without bound, the ingest-interval echo envelope only extends directionally (2× headroom in the direction of motion), so the slower integrator falls off the BACK of the envelope and adoptAuthoredAngles snaps backward every few ingests (~49.5° sawtooth reproduced while rotating). No threshold fixes cumulative unbounded divergence, and extending the envelope backward would un-fix the static death-cam grip the gate shipped for. Camera steady-state behavior is restored to exact 5.2.0 semantics: custom-kind-only angle mirror (writeCustomCameraAnglesBack), parameters-only steady-state updateConfig, no divergence-adoption path. The ruling's durable shape (write-provenance authored-authority latch, #9059's own named follow-up) is ledgered separately and rides a later fold.
  • Scripted-primitive transparent draws now use the shared sort-order.ts convention (planar view depth + renderOrder fold) instead of radial camera distance, restoring paint-order parity with sprites/text/effects (resx's parity report, #8776). Draw-order change only, and only for transparent scripted-material primitives — three-era parity restored.
  • Reverted the 10x desktop terrain-decoration instance raise (#9095, squash 2598900b). The raised budget overflows the default 128MiB storage-buffer binding on dense default decks: the device-budget clamp caps instance COUNT (2M) but nothing caps binding BYTES, so a new game's default meadow deck binds 2M × ~77 B ≈ 154,838,720 B > the 134,217,728 B default maxStorageBufferBindingSize (the adapter supports 4GiB, but we never request it via requiredLimits) — instant GPU validation failure at CreateBindGroup ("terrain decor sampler", entries[8], compute stage) on first load. Desktop returns to prior values (decorationBudgetScale/decorationDistanceScale 1, scatter admission ceiling 200k — geometry-budget.ts / perf-static-data.ts SCATTER_BUILD_INSTANCE_BUDGETS) while a bytes-aware budget + adapter-limit negotiation is designed. The rim height ramp (#9084) is unrelated to buffer sizing and stays.

Lume: engine v5.2.0

Released July 10, 2026

The big one: the rendering engine was rebuilt in-house.

  • Same look, faster loads — and custom materials keep working exactly as documented: measured against the full catalog, every published custom material and look keeps working exactly as before.
  • Games use a fraction of the GPU power they used to (typically 30–95% less), which means cooler machines, quieter fans, and much longer battery life at higher frame rates.

And everything else that lands with it:

  • Switching camera kinds (e.g. a first-person ↔ third-person toggle via setCamera) now cuts instantly to the new camera instead of gliding the view through your character. Zoom easing and camera smoothing within one camera are unchanged.
  • Painting grass and flowers feels completely different now: strokes go straight into the massive GPU grass system, so you can paint whole meadows — hundreds of thousands of blades and blooms — with instant feedback under the brush.
  • Erase works both ways: clear the flowers you painted, or thin out naturally-growing grass to carve paths and clearings.
  • Every decoration layer in your world gets its own Foliage brush, so you can hand-shape where the grass grows.
  • Edits to generator scripts now reach everyone in the room. Before, if you (or Savi) edited a script that generates scattered objects or scripted spline shapes, the world quietly split: your server kept the old shapes' fingerprints while every player's game rebuilt with the new script — so flowers, rocks, cloud-seas, even player rigs could turn permanently invisible for everyone else, surviving rejoins, with no error anywhere. Now a generator edit rebuilds those objects for the whole room the instant it applies, and each rebuilt piece remembers which script version made it, so the engine can tell "still syncing" apart from "actually broken" instead of leaving things invisible.
  • Grass is alive now! Fields sway and gust with your world's wind direction — turn the wind and the whole meadow leans with it.
  • One line gets you a full meadow: deck: "meadow" (or clover, alpine, autumn, lavender) plants tuned grass and flowers that inherit your ground colors.
  • Ground cover is way denser near you — thick carpets instead of sparse tufts — and every blade varies a little in color, so fields stop looking flat.
  • New worlds start with a living meadow instead of bare ground.
  • Buttons, doors, chests, shops, and vehicles now respond instantly for everyone — not just the player hosting the place. Pressing E used to wait a full network round trip when someone else's machine was simulating the object; now the interaction runs on your machine the moment you press, and the rest of the room converges through the normal write lanes.
  • When a sprite animation can't be generated, Savi now learns exactly why and can fix it right away — instead of everyone waiting on retries that could never succeed.
  • Point and spot lights now illuminate every material — matte (Lambert), glossy (Phong), cartoon (Toon), custom scripted materials, and water — not just standard PBR surfaces, sprites, and tilemaps. A lamp next to a toon house lights the house; lanterns reflect in lakes at night.
  • Switching a place between 3D and 2D while people are in it now updates everyone's look immediately. Before, players (and objects) kept their old-mode visuals — in a 2D world your character could turn invisible because it was still wearing its 3D avatar — until each person refreshed. Flipping back to 3D restores 3D models and profile avatars the same way. Also fixed 2D worlds silently not showing image-based (png) models placed in the world spec.
  • A lag spike no longer permanently costs you your drawn textures or scripted materials. Before, one bad stretch (a busy device, a backgrounded tab) could park a texture script or flatten your scripted materials for the whole session — things like a sprite's drawn halo or a planet's shader skin went missing until someone refreshed. Now the engine retries before parking, one stalled texture variant no longer takes down its siblings, and once the frame rate recovers, parked work comes back on its own — carefully, one piece at a time, re-parking only a piece that actually causes trouble again.
  • Walking through a portal now starts loading the destination's models, textures, and sounds immediately instead of after the server confirms the travel — and places you haven't visited yet warm quietly in the background during idle frames, current place first. Transitions hold their loading veil for less and same-dimension arrivals land with real art instead of placeholders popping in. Nothing costs the current place a frame, and nothing changes about who is allowed to travel.
  • Other players and NPCs now start moving with a visible wind-up and settle when they stop, instead of moonwalking into their walk cycle or skating to a halt — network delay reads as character weight, not lag. When someone's connection stalls, their avatar eases to rest instead of running in place. Your own character is untouched, and it stays out of the way of your own animation: actors driven by your mixer channels or an authored speedOverride are never touched, and interpolation: false disables it with the rest of motion smoothing.
  • Hits land where they look like they land: on jittery connections, damage numbers, hit flashes, and impact sounds from other players now present the same frame as the body they land on, instead of flashing up to ~130 ms ahead of it. Your own actions stay instant.
  • Behind-the-scenes: sprite loading failures now record exactly why they failed, so problems get fixed faster.
  • HUD animations driven by game state (momentum counters, timers, scale pops, fades computed in your ui.js render) update 3× more often — 30 times per second, up from 10 — so they look noticeably smoother. For perfectly smooth motion at any display refresh, keep using CSS transitions/keyframes in your UI html. New knob: api.patchEngine({ ui: { targetFps: 10 } }) (5–30) if you'd rather dial a mostly-static HUD down.
  • A second of flaky wifi no longer leaves permanent broken-image icons in your game's UI. Images that fail to load now quietly retry on their own (after 2s, then 8s), and even one that gives up comes back on the next screen change or image swap — refreshing the page is no longer the only fix. Your own onerror fallbacks on images keep working exactly as you wrote them.
  • Guards, creatures, and anything using canSee/awarenessOf now notice players the moment they come into view instead of up to a couple of tenths of a second late. Sneaking past a patrol, chases, and "the shopkeeper looks up as you walk in" all feel more alive — and the authored feel (awareness rise time, vision cones, gradual loss of sight) is untouched.
  • Lights work in 2D worlds again — lanterns pool warm light on the ground, windows glow onto the yard, a firefly carries its own drifting glow — and dark scenes sit at the same brightness they did before the renderer swap instead of reading washed-out.
  • Fixed custom materials sometimes staying stuck as flat gray/tinted after joining or refreshing a world — the engine now notices when a material didn't finish building at join and retries it automatically, including after reconnects.
  • Custom geometry that swaps textures mid-mesh (a table with oak top and weathered legs) shows its textures again, and lantern-style objects that mix glass with solid parts draw in the right order — the solid cap and base sit in front of or behind the glass like they should, instead of the glass smearing over them.
  • castShadow: false and receiveShadow: false on objects now actually work — models stop casting when told not to, and both models and primitives set to not receive shadows shade fully lit instead of darkening anyway. Worlds that turned shadows off per object for looks or performance get that back.
  • Textured surfaces no longer turn flat white when a world mixes texture sizes — the iso-town cars keep their paint and rust next to the playground's oak tables, no matter which loads first.
  • Fixed models turning entirely white when a wind-sway or other vertex-animation material script was applied — the staging-zoo pine that waved correctly but lost its bark and foliage colors. A script that only moves vertices now keeps the model's own textures, colors, and vertex paint; scripts that set their own color still win.
  • Fixed skinned characters in crowds sinking waist-deep into the ground (and rendering at the wrong size) when their script asked for an animation name the model doesn't have — they now stand on their feet in the model's rest pose, exactly like a single copy of the same character would.
  • Lights, particles, and scripts you attach to an object's children now actually run when your game boots — before, they could silently do nothing until an unrelated edit woke them up. And any script can now address a child object by the id you gave it in the spec, not just from its own parent.
  • When an asset's generation fails hard on the server, your game now stops hammering the dead asset (which was slowing everything else down, including the retry that would fix it) — Savi gets one clear message saying what happened and that regenerating the asset is the fix, and once it's regenerated it loads again on its own.
  • If your scripts spawn objects into a place nobody has entered yet (like pre-building a match arena from a lobby), you now get a clear warning telling you those objects would silently vanish — with the fix: bake them into definePlace's objects[] or create them at entry with enterPlace createIfMissing.
  • Worlds whose scripts re-set the same values on every boot no longer jam the save pipeline — the engine now recognizes "nothing actually changed" and skips those saves instead of retrying them forever, so real edits and state changes save promptly again.
  • Characters and objects with "breathing" or squash-and-stretch scale animations no longer secretly rebuild their physics every frame — worlds full of animated creatures run smoother, and idle characters stay cheap like they're supposed to.
  • When a bunch of script-generated objects appear at once (a whole block of generated buildings restocking), other players' games no longer freeze for a moment — the shapes stream in over a few frames while everything else keeps moving.
  • You can outline things in up to 8 different colors at once now (up from 4). When a world asks for more than fits, the most important outlines stay visible — your god-mode selection no longer vanishes — and Savi is told exactly which outline colors got dropped instead of them silently not appearing.

Known issues (being polished on staging):

  • In dense scenes with many overlapping transparent sprites, front/back ordering can still occasionally pop wrong — the specific reported cases are fixed, and deeper ordering work continues.
  • Water detail is temporarily simplified: ripple normals, crest foam, and caustic sparkle use placeholder patterns until the real textures are wired into the new renderer.
  • A few very advanced material scripts that reach past the documented vocabulary pause safely with a message naming what's missing instead of rendering — that vocabulary is being filled in now. Everything written from the Custom Materials and Looks docs works unchanged.

technical notes

THE RENDERER SWAP (the 5.2.0 headline):

  • Replaced the vendored three.js r185 fork with an in-house WebGPU renderer (lume) and WGSL shader system (shade): one GPU layer talking GPUDevice directly, one frame graph per frame, storage-buffer-instanced indirect draws, async pipeline builds that never block the frame.
  • shade compiles the taught TSL-compatible node vocabulary to WGSL synchronously and deterministically on the CPU; shader build errors surface at authoring time with ref attribution instead of being swallowed.
  • The sim→renderer wire protocol, handler structure, and material-key contract are unchanged; the three-era guard industry (error interceptor, wgsl-dominance, sampler-decl culprit, MSAA resolve probe, invalid-attribute guard) died with its cause.
  • Deleted the fork build machinery (vendor/three, build:three, fork patch tests) and the three / @types/three / three-mesh-bvh dependencies.

Everything merged since 5.1.13 rides this version:

  • Remote actors ride an adaptive playout delay: the interpolation buffer sizes itself to each peer's measured arrival jitter (floor at the previous fixed 1-tick offset), so unstable connections get smooth remote motion instead of stutter, and stable ones keep today's latency. Anticipation and juice samplers resolve their history offsets through the same playout clock — one timing authority for every stacked presentation effect.
  • The magic-cdn async probe now classifies a 5xx that carries the cook-kill tombstone signature (Retry-After header once kiln ships it; the stable "process-killed mid-cook" body fingerprint as fallback) as a terminal server verdict: the verdict joins the per-URL tombstone map (the ledger-1023 quota mechanism, generalized), so re-asks answer locally with zero network until the window lifts, and every retry ladder parks on the window instead of burning backoff — the sim AssetService (previously 5s→60s forever; dig 628c4096 measured 40k+ requests against one dead sprite in 50 minutes across real player sessions), the lume texture ladder (previously a 15s cooldown forever), and the renderer model ladder (attempts frozen, mirroring its quota park). Parked assets surface once with the tombstone's teaching detail: models on the model-load-failed rail immediately (a parked-at-attempt-1 entry would never reach the attempts-5 diagnostic), textures on the texture-load-failed rail (the ledger-895 code, allowlisted but emitter-less since the lume port). Window expiry re-probes exactly once — an admin regenerate or tombstone clear resumes normal loading automatically, and ordinary transient 5xx keeps the existing ladders byte-identical.
  • The renderer camera smoother is now regime-keyed on the sim-declared camera kind (rendererCamera.kind, undefined for authored/non-interactive cameras): a kind swap CUTS every smoothing layer — the target timeline ring restarts at the new pose and renderPos/renderCenter/renderRot/currentOrbitDist/fpBlend snap — so the new camera renders at its own pose next frame (ledger 1057). Previously the smoother treated two different rigs as one continuous target: a setCamera replace between built-in first-person and third-person (distance 5.5) pose-interpolated the straight line between the head pose and the orbit pose — currentOrbitDist lerped 0 ↔ 5.5 while the snapshot ring blended the swap tick pair — a deterministic ~0.3–0.6s glide through the top of the player avatar, both directions, custom-script toggles included. The sim already cut (kind transitions clear camera state and write the new pose in one tick) and the renderer orientation already snapped to the seed yaw/pitch on kind change; the smoother was the only layer gliding. Same-kind parameter changes are untouched: orbit-distance easing (zoom, spring-arm follow-through, occlusion recovery), zoomTo sim-authority windows, place-transition tick rebasing, spawn framing, and FOV blending (god-mode enter/exit relies on it) all keep today's behavior — a same-kind full replace is indistinguishable from parameter drift on the wire and in the sim (stable compiled-behavior identity keeps camera state), so it keeps smoothing by design. Pinned in camera-smoother.test.ts: kind swap = at the new pose next frame both directions; same-kind distance change still eases.
  • setCamera/getCamera docs now state the pitch conventions: initialYaw/initialPitch are radians while getCamera() returns degrees, and the pitch sign flips between kinds (third-person positive = camera above looking down; first-person positive = looking up) — carrying getCamera().pitch into initialPitch across a kind swap needs a convert-and-negate. Doc-only: unifying the convention would reinterpret stored initialPitch values in shipped specs.
  • Behavior scripts animate entity scale every tick (breathing, walk squash) with values that never repeat, and full-precision scale fed collider signatures — so every write was a signature change → collider destroy+recreate every tick → colliderSetEpoch churn that disabled the character-controller rest-skip for every character in the place (ledger 1159, #8713). Scale components in collider signatures now quantize through quantizeColliderScale (sign + exponent + top 5 mantissa bits of the f32, round-to-nearest — adjacent buckets ~1.6–3.1% apart), applied in both physics engines (rapier buildColliderSignature, mantle runtime). The bucket moves only when scale moves materially; between rebuilds the realized collider can lag the render scale by at most one bucket (~3.2% worst case, within the existing render-vs-physics tolerance class — feet offsets keep tracking full-precision scale), and a rebuild still realizes the collider at the exact current scale. Pure f32 bit ops, so client and server always agree on when a collider rebuilds. Only scale is bucketed, and only in signatures.
  • God-mode foliage re-target: scatter brushes whose content is pure sprite cards (grass/flowers — no model, no physics, no behavior) on heightmap terrain now paint a decor:<layerId> density field read by the GPU decoration sampler (300k-instance budget) instead of minting a 500-entity scatter bed. The brush mints a paintedOnly decoration layer once into terrain.decorations (deterministic content-hash id); strokes persist as compact field data in place.fields and replicate like every painted edit. Model/behavior templates keep the scatter-bed path unchanged.
  • New DecorationLayer.paintedOnly flag: zero baseline growth — instances appear only where the layer's painted decor:<layerId> field is above zero (material bindings ignored for it).
  • Decoration sampler: painted density rides the existing tile-map storage buffer (camera-follow grid per painted layer). Positive paint force-binds candidates on any material and lifts cluster/noise thinning (bounded by the LOD ring fade); negative paint thins below the authored baseline toward bald at -1. Unpainted layers take the exact previous path.
  • Per-layer "Foliage" brushes synthesized from the place's authored decoration layers (like the Materials shelf) — paint grows a layer past its natural coverage, erase thins it below baseline.
  • New client sync lane: tome/decoration-density-sync publishes composed decor:* field snapshots to the renderer via the terrain/decoration-density component; paint→visible lands within the frame that applies the dab's tick.
  • Generator-script content joins the object-diff contract (ledger 1069 strand 2, the class killer). The diff signature carried script content only for an object's OWN primitive.kind:"scripted" (interpreter buildObjectDiffSignature), and scatterChanged was value-equality on the scatter spec — so editing a scatter-template or spline generator never re-minted the server-side generated children: the server kept DrawMesh pointers minted from the OLD source while every client derived with the CURRENT source, a deterministic non-converging mismatch (stable wrong sigs across attempts/sessions/clients, e.g. island-hero-flowers/scatter/0 deriving bf2c835280 vs expected 3a5ee21f15), invisible entities that ride the report budget into a park and re-mint identically on re-join. Now collectObjectGeneratorScriptRefs (own scripted primitive + own spline script + every scripted primitive/spline in scatter templates, incl. multi-part template assemblies, via scatter.ts collectScatterGeneratorScriptRefs) feeds the diff signature, defDependsOnChangedScripts, AND the scatter-change hint, so a generator edit tears down + re-mints the bed's children synchronously inside the same applySpec (instant spec→ECS, no queue). Spec-spline products already re-lofted through expansion buffer inequality; that parity is now pinned by test.
  • Mint-from-the-recipe invariant (part 2): all scripted-primitive mint sites (interpreter applyAppearanceProps + live setProperty, now one shared mintScriptedPrimitiveRecipe) derive the pointer signature from the recipe as it will exist AFTER sanitize + wire round-trip, and re-store that wire-faithful value (#8231's stored-value-IS-the-wire-value discipline applied to derive inputs). sanitizeWireSafeStateBag deliberately passes JSON-representable values that JSON itself still rewrites ([undefined] → [null], class instances → their JSON projection, -0 → 0) — deriving from the pre-wire value minted signatures no client could ever reproduce (the Koi player-rig sub-case's named candidate). A mint-time self-check warns LOUD (bespoke-geometry-mint-self-check, greppable + additive-only) naming the first property-access-divergent param when storage/wire changed the recipe. Scripted-spline expansion mints with the same discipline (loft + children() run against the round-tripped inputs; recipes store them verbatim).
  • Recipes carry the mint-era script content hash (scriptHash, recipe-internal on TomeScriptedPrimitive/TomeScriptedSpline component values — no new spec fields), and the existing derive-mismatch warns (bespoke-geometry-derive-mismatch, scripted-spline-derive-mismatch, names unchanged) now distinguish script-content skew ("spec lags/leads the mint; should converge when the spec syncs and the server re-mints") from true divergence under identical content (nondeterministic script or non-stored derive inputs; unhealable) — with scriptContentSkew + both hashes in the payload.
  • Non-goal honored: the renderer's signature strictness is untouched — wrong-sig geometry still never renders. Pinned in generator-edit-remint.test.ts: generator edit → scatter children re-mint end-to-end with remote-client derive joining the new pointers; unrelated script edits leave beds untouched (no respawn storm); spline generator edit parity incl. recipe hash re-stamp and remote derive; player-rig-shaped state-bag params (Infinity/Date/Map/[undefined]/-0) through the real sanitize path derive equal on a fresh client; mint self-check loud on the constructed mismatch and silent on wire-faithful recipes; warn-text skew/true-divergence split.
  • Decoration wind sway is a directional gust field driven by the global sky wind (atmosphere.windDirection/windSpeed → sky-state → new Frame.wind uniform lane @ 368; frame struct 368 → 384 bytes): traveling noise advected along the wind, mean directional lean, per-instance-phased lateral shimmer. The three-era directionless value-noise sway body is deleted.
  • useTerrainColor is implemented (was a documented no-op since the lume port): the placement sampler blend-weights a terrain material tint palette (new 64×vec4 uniform block read from the atlas tint rows) and packs the ground tint RGB888 into the previously-unread per-instance .w output lane; tinted draw variants decode it — textured sprites keep their luminance pattern (three-era pattern constants), grass cards get a root→tip lift. No new sampler storage buffer (stays at the 8-buffer floor); zero extra bytes per instance.
  • New settings.alive regime: items without authored wind sway gently by kind default (grass 0.06 / sprite 0.05), items without explicit useTerrainColor tint. Explicit per-item values always win. Absent = pre-alive interpretation.
  • Deck presets: decorations.deck: "meadow" | "clover" | "alpine" | "autumn" | "lavender" (or { name, on, density }) expands into composed layers at the render boundary (decoration-decks.ts / decorations-sanitize.ts); composes with hand-authored layers; only verified CDN sprites + color-authored cards.
  • Near-field density: per-item draw distance (maxDistance per item; ground-cover cards default 80m), item counts ask over the item's own placement disc, LOD ring splits clip + re-normalize per item, cull exp far follows the item distance. Minted meadow deck lands ≥10/6/4 instances/m² near-field on desktop/tablet/phone within the 200k/100k/50k scatter ceilings (pinned by decoration-decks.test.ts acceptance math).
  • Per-instance colorJitter (hue rotation + value swing hashed from instance XZ, zero buffer traffic; defaults subtle on cards, off on primitives). Grass-kind tipColor now actually renders (base→tip gradient in the item uniform — was silently ignored since the lume port).
  • New world mints (starter-3d) ship decorations: { deck: { name: "meadow", on: ["grass"] }, settings: { alive: true } }.
  • The batched skinned path's missing-clip hold now points instances at a baked REST-POSE palette row (pose-bake appends one row after every clip) instead of the three-era anim.w < 0 → identity skinMat sentinel. The sentinel's semantics didn't survive the lume port: three's vertex math sandwiched the palette in bindMatrix/postMatrix constants, so identity collapsed to rigLocalMatrix — the mesh in feet-anchored model space. Lume's palettes are already model-space and the sandwich is gone, so identity skinning rendered raw quantized bind space — wrong size, centered pivot, waist-deep in the ground. The WGSL identity branch is deleted (no writer remains); batched and clone paths now hold the same rest pose across the horde↔character demotion seam.
  • A server tick replicating a batch of scripted-primitive spawns (specimen: a food-streamer restock landing 8 tier-7/8 building generators in one tick) derived every recipe synchronously in one client netIngest pass — seeded RNG + noise + full geometry construction in a single frame, the felt hitch (ledger 1159, #8716). The sweep now only discovers work; builds drain through a FIFO capped at 2 per pass. Entities exist in ECS the instant the delta applies — only BespokeGeometry realization spreads, the same async-arrival shape the bespoke pipeline already handles (geometry-wait treats an unresolved pointer as a wait before reporting). Drain re-reads current world state: a recipe mutated while queued builds once with the final value; a despawn while queued drops cleanly; dead entries spend no budget. Up-to-budget spawns still derive the same pass they land, and the queue order is deterministic and cross-client identical.
  • Interactions run at the INTERACTOR (docs/interactor-context-interactions.md): onInteract on an entity the interactor doesn't simulate no longer round-trips to the target's simulator — the interactor's client runs the hook itself, the same tick as the press, and every write routes through the existing effect lanes (own-state immediate; spawns re-rooted into the interactor's envelope, born at 0 ms; cross-writer writes as guarded intents sourced at the interactor; emit/music/noise/purchase/enterPlace/persistence on the validated Command rails under an interaction grant registered by the new rail.interactClaim). The hook's continuations (timers, api.on subscriptions, job callbacks) carry the execution context and run where the hook ran. The interact.fwd/interact.verdict forwarding machinery, the routed-pending table, and the interaction sweep are deleted — the owner/host can never double-run a hook by construction. rail.interact survives narrowed to server-simulated targets (no PlaceMembership). One scoped authority change: rail.transferControl accepts a grant-backed SELF-claim on unowned remainder targets (the vehicle-mount hook now runs on the mounting player's machine), under strict deny tiers — never someone else's player, never another client's envelope, never a second controller.
  • resolveClientAuthTargetSimulator (the server's who-simulates-this-entity answer, consulted by intent verdict routing, rail.interact classification, and every control-transfer simulator check) now consults the sim-lease table, completing the resolution chain owned → leased → hosted → unhosted on the server side. Before this, a sim-leased loose dynamic resolved to its place host — mis-routing control-switch release legs and intent ratification to a machine that no longer simulates the entity.
  • The scripted-material join watchdog now verifies BUILT-state instead of delivery: parked refs join the sweep predicate (join-window build-failure parks get one bounded unpark-and-refresh; an all-parked sweep logs loudly instead of disarming satisfied), built-state matching requires a stamp matching the anchor's current intent ref with no scripted-intent fallback in the subtree (fixes chunk-sibling masking), and the watchdog re-arms on library delivery (reconnect/place-resync/recovery snapshots each get their own sweep window — previously one sweep per renderer session, permanently disarmed after). Park entries now store their park reason.
  • Two lume port drops in the lit-2D lanes (sprites/shader.ts, tilemaps/shader.ts), one fix. (1) Froxel lights restored — three's SpriteLightingModel received every clustered point/spot light through the froxel loop calling direct() per light; lume's port shaded lit 2D art against sun + ambientMean only, so every authored point/spot light contributed exactly nothing in 2D places (Farm Dusk's 34 lanterns/windows/fireflies rendered as unlit sprites on a flat field). The shared froxel machinery (cluster lookup, distance/cone attenuation, atlas shadow taps, per-light sample) is now factored out of the pbr-only clusteredEvaluateWgsl into one chunk both consumers include: pbr folds the identical per-light sample through Cook-Torrance, the new clusteredWrapEvaluateWgsl folds it through wrap-Lambert (lighting_wrapClusteredIrradiance, carried on LightingModule.wrapEvaluateDefs) — extra-directional prefix + froxel list, same clustered mechanism as 3D, no bespoke 2D light path, local shadow-atlas reception included (three parity). (2) BRDF_Lambert 1/π restored — three folded every 2D light term (sun, clustered, ambient) through albedo/π; lume's 2D lanes dropped the factor while the 3D lanes kept it (shading.ts), measuring ~2.5× hot linear luminance on 2D-top vs 5.1.13. The whole light sum now scales by 1/π; emissive (three's finish()) and the cooking wash stay unscaled. Unlit sprites/tilemaps are untouched — lit: false stays byte-identical, zero-cost-when-unused holds.
  • The lume primitives store collapsed every bespoke mesh into ONE vertex-PBR lane slot, dropping the three-era planBespokeDrawGroups split — ctx.albedo() texture runs never bound a texture (the whole mesh sampled layer 0 and rendered its packed color: the Two-Texture Table white regression), entity-level map on bespoke meshes was equally dead, and material: { transparent: true } dragged a mixed mesh's opaque faces into the alpha pass where lume has no per-group draw order (the pesky-lantern cap/base compositing over/under its glass). planBespokeGeometrySlots (features/bespoke-geometry) now ports the ledger-956 plan — a face is translucent when any corner's vertex alpha < 1; each face belongs to its covering texture run or the −1 base; slots are canonically ordered so identical content plans identically — and buildBespokeSlotGeometry compacts one slot into standalone buffer data with whole-mesh-resolved UVs/normals (slot texture mapping and smoothing match the old single-geometry groups). The store allocates one lane slot per material slot ({ kind: "lane", parts } representation; matrix/visibility/outline/bounds fan out over parts), derives per-slot overrides via the three-era materialForTexture rule (run texture becomes the slot's map, authored color recolors it as tint), and resolves per-slot texture state through the texture arrays with the same held-layer refcounts + arrival subscriptions primitives use. Alpha semantics: opacity < 1 or overlay still blends every slot; transparent: true forces whole-mesh transparency only when the geometry carries no alpha split of its own — a mixed body's opaque hull keeps depth writes and shadow casting (the ordering fix), and an all-translucent body blends with no entity override at all. The orphaned buildTexturedBespokeBufferGeometry/groups half-port (consumer died in the lume cutover) is deleted.
  • draw/shadow intent now reaches actual render behavior on lume, all three legs of jure's report. Models: the shadow handler routes intent into the models store (held per owner entity, so spawn-frame ordering and model re-adds keep it); intent partitions static/horde batch identity via a signature shadow segment, castShadow:false gates batch/character/scripted caster draws (and the character caster-sphere feed), receiveShadow:false splits the color pipeline. Primitives: the lane key's receiveShadow flag finally reaches the shader — the clustered lighting evaluate takes a compile-time receiveShadow gate that skips the sun-cascade AND local-light atlas taps (lane pipelines split on it; depth-only caster pipelines normalize it away), oversized direct-texture pools key on intent, and intent arriving before the primitive record (the shadow handler collects first on spawn frames) is held for record creation instead of dropped.
  • The shared per-channel primitive texture arrays lock a first-source template (512²×full-chain on desktop), and #8493 routed bespoke slot textures through them with no fallback — in any world mixing KTX2 cook dims (the staging zoo: playground oaks 512×512×10, iso-town car panels 64×64×7), whichever shape loaded second was refused (template-mismatch) into the masked white layer 0 and rendered flat (ledger 1091). Routing is now a single load-order-independent predicate (PrimitiveTextureArrays.routing()): off-profile dims always route to the existing per-texture direct-bind pools (oversized.ts, extended with a vertexPbr lane layout so bespoke slots can ride it — a bespoke representation's parts now mix shared-lane and pool slots, with matrix/visibility/outline/bounds/release handling both); profile-dim sources join the shared array unless the locked template disagrees on format/mipCount, in which case they pool too — the one first-admitted-wins residue moves WHICH path a texture binds, never whether it renders, so the same world renders identically regardless of pen residency order. Primitives' dims-only oversized threshold folds into the same predicate (a profile-dim format conflict previously white-placeholdered there too). The alternative — shape-bucketed arrays keyed by (channel, format, dims, mipCount) — keeps off-profile textures batched but forces per-bucket lane splits through the one-array-view-per-channel group-2 bind contract plus texture resolution before lane selection in both reconcile paths; pool count per world stays small (distinct off-profile texture bindings, 2 in the zoo), and this change doesn't foreclose bucketing later if that grows.
  • Magic CDN fetch errors now carry the server's error-body detail: ensureMagicCdnAssetReady reads the JSON detail/error from 4xx and 5xx probe answers into MagicCdnFetchError.detail (previously only fingerprinted cook-kill 502s kept their detail), and the sprite atlas metadata fetchers (fetchRangeClassified, fetchSpriteAtlasMetaFromKtx2Classified, fetchSpriteAtlasMetaFromPngClassified) classify failures with a failureDetail field.
  • The sprite-atlas-missing engine diagnostic branches on the failure class: a terminal 4xx (the CDN's verdict that this exact name can never generate, e.g. the over-long-filename reject — auth-window 401/403 excluded, since the hydration lane runs unauthenticated) now reports "FAILED to generate (HTTP status: detail) — fix the named cause or regenerate under a different name" instead of "hasn't finished generating — do not change the asset URL", which taught the opposite of the fix on deterministic failures (grok-imagine incident, 2026-07-09). The same verdict parks atlas hydration for the session instead of re-fetching the doomed URL every 30s.
  • All lit material paths now participate in clustered lights (ledger 1096, jacob's ruling: "all materials including custom ones need to participate in clustered lights") — the materials half of lume known-issues item 15, completing what #8491 shipped for the 2D lanes. Lambert/Phong/Toon meshes previously shaded sun + flat ambient only (materials/shading.ts cheap snippets); water shaded Cook-Torrance sun + flat ambient only (water/water-wgsl.ts). Every path now folds the froxel point/spot list AND the extra-directional prefix through the same shared per-light sample the PBR path uses (lighting_sampleClusteredLight — attenuation, cone falloff, atlas shadow taps can never drift per model). Folds per material model: Lambert rides the existing wrap chunk at wrap 0 (plain N·L — lighting_wrapClusteredIrradiance(N, P, fragCoord, 0.0)); Phong adds its Blinn specular per light next to the Lambert diffuse (clusteredPhongEvaluateWgsl); Toon bands each light's angular term through the material's band function before the sum (clusteredToonEvaluateWgsl — three r185 ToonLightingModel parity; banding the summed composite instead would leak sun radiance onto sun-averted faces through the band's ceil rounding); water folds Cook-Torrance per light (metal 0) via the evaluate library's lighting_clusteredLight. Sun + ambient terms are byte-unchanged in every path — the clustered term is purely additive, so scenes with no local lights render as before. The PBR path is byte-identical (verified by generated-WGSL diff of clusteredEvaluateWgsl + fully assembled pbr/physical modules against master — the #8491 regression gate); the wrap chunk consumed by sprites/tilemaps is byte-identical too.
  • Scripted materials (material: { kind: "scripted" } — TSL MeshLambertNodeMaterial/MeshPhongNodeMaterial/MeshToonNodeMaterial) get the fix through the same assembleMaterialShader snippets; the look-system isolation contract is untouched (assemble throws still park the ref and fall back to the Std/PBR lane; pipeline build failures still park with attribution — a scripted material's lighting hookup can never break the main render). Matcap stays lighting-independent by design (three parity: lighting is baked into the sphere texture — it takes no sun either), and cheap models against the v1 sun-ambient module (capture booth) degrade to sun + ambient exactly as before.
  • New WGSL-resolution audit test: every assembled model's shade_/lighting_/lume_ calls must resolve and each fn must be defined exactly once — WGSL validates all declarations before dead-code elimination, so a chunk referencing a fn its module never includes is a pipeline compile failure (on the scripted lane: a parked material). This audit is what forced the phong/toon folds into separate self-contained chunks.
  • Named split-out (NOT in this PR): water's permanent placeholder normal/foam/caustic texture binds (water/water-store.ts TODO(lume-water-textures)). Receipt in-tree: the placeholders are exact three-era parity (master's createWaterNodeMaterial was only ever constructed without texture options; the unused setWaterTextures seam had zero production callers and was deleted). Wiring the real maps is feature work in the renderer's asset layer — lease water/normals + water/crest-foam and composite the 31-frame caustics sequence into one 2d-array lease — not a bounded change in this pass.
  • A live place-mode flip (updatePlace 3d ↔ 2d-side/2d-top) now re-runs the appearance contract for already-spawned entities in the flipped place (ledger 1083). Appearance dispatch is mode-dependent — the #509 sprite-wins contract and the png.glb→sprite implicit conversion read the place mode — but the flip previously triggered terrain dirtiness only: object defs and the player template are unchanged by a pure flip, so neither diff path re-dressed anything, and a spawned player kept its pre-flip runtime-written 3D avatar (invisible under the 2D camera) until rejoin/refresh (Sandsong QA dump ad87396e, triage 99703695). Now applySpec collects modeFlippedPlaceIds and (a) re-applies applyAppearanceProps from the current spec defs for the flipped place's objects, contained per object, and (b) ORs the flip into the player template-diff trigger, so players re-dress through the existing #509 machinery (profile-avatar resolution stays in one place, both realms decide from the same replicated inputs, and the Draw* writes ride normal component replication — no bespoke re-dispatch lane). Reverse flips (2D→3D) restore the model/avatar path through the same pass. First apply stays exempt, mirroring the player block (a fresh client join carries replicated live state a def re-apply would destroy).
  • Fixed a dead arm the re-dress exposed in applyAppearanceProps: the implicit model→sprite conversion for 2D places wrote DrawSprite directly, then the function's own trailing sprite pass (next.sprite unset → else-remove) stripped it in the same call — spec-authored png.glb objects spawned in 2D places rendered nothing on this path (the live setProperty lane has its own conversion and was unaffected). The implicit sprite now rides next.sprite through the single trailing write lane.
  • Known residual, named not fixed: runtime-spawned scatter children keep their pre-flip dress until scatter refresh (terrain updates refresh them; a pure mode flip does not), and physics topology (e.g. 2D collider needs) is not re-derived on flip — the pinned gap was visual re-dispatch.
  • Pinned in player-sprite-2d-avatar.test.ts (ledger #509's suite): live 3d→2d-top flip sheds a runtime-written avatar model for the authored sprite on the same entity with no rejoin; 2d→3d restores the profile avatar (animated3DCharacter: true) and a template model; object re-dispatch on flip (png.glb→sprite conversion, runtime model write shed against an authored sprite); and the 2D spawn-path implicit sprite conversion.
  • Runtime spawn({ place }) targeting a provably non-resident place in a multiplayer world now emits a teaching warning at the API boundary (ledger 1077): the spawn succeeds locally on the executing client but the server's create-row gate rejects entities that are neither sender-owned nor unowned members of the sender's hosted place, so the object ghosts untick'd and every follow-up write dies unknown_target. The check uses the server's PlaceResidencyResource and, on the executing client, the replicated TomePlaceHosts table (own hosted place, any live host, or a sender-owned envelope spawn all stay silent). One warning per (place, burst) — never per spawn — on the mutation-warn rail, plus one engine-error DM per place per room life (server: DM notifier; client: the behavior-fault wire onto the engine.diagnostic rail). Singleplayer is untouched — the pattern genuinely works there. This is the soak form; promotion to a throw (enterPlace's nonexistent-place precedent) is the planned second step.
  • Two receipted persist-path defects (ledger 1157, #8711). (1) Boot-deterministic onSpawn re-assertions (patchState/replaceState of values already in the spec) minted 364–1604-mutation batches that were 100% content no-ops, wedging rooms against kiln's 60s apply wall indefinitely. A state write that changes nothing against the live state is now dropped at the applier — guarded by the durable-mirror veto: suppression happens ONLY when the write is also a no-op against the object's GameSpecResource row (the durable-intent mirror, which state writes never advance), so a live-only write followed by an identical withPersistence commit still records. At boot, hydration makes mirror == live, so the wedge-killing property stands: boot re-assertion batches collapse to zero. Per-tick debug-level count instead of per-mutation spam; the veto can only force recording, never suppress more (A→B→A still records both). (2) The #8202 reconcile ladder's bound didn't survive host recycles — teardownActiveSimulationHost reset persistReconcileRounds while pendingMutationQueue survived, restarting the ladder on the same stuck batch forever (~18 gateway-timeout kiln invocations per ladder, indefinitely). The round count is now keyed on the batch's content identity and rides with the queue across recycles; a genuinely new batch composition honestly starts a fresh ladder. The terminal drop's loud rails stay exactly as #8202 built them.
  • Enfeul's "only 4 different colors" report turned up two real bugs beside the cap (ledger 1161, #8721). Truncation direction was INVERTED — assignOutlineSlots sorted priority ascending and kept the FIRST maxSlots, so god-mode selection outlines (priority 4/5/7, meant to read above gameplay highlights) were exactly what vanished under gameplay-highlight pressure; the LOW end is dropped now, with ascending order preserved among survivors for composite stacking. maxOutlineSlotsForTier was dead code in lume — no caller passed maxOutlineSlots, so phones got the desktop cap; the renderer session tier is now wired through frame inputs to the post chain (desktop 8 / non-desktop 2, the pre-lume tiering). And over-cap drops were silent by construction (highlight() has no error path; state reads back as set) — a new outline-slots-exhausted diagnostic names the dropped styles' colors and the cap (listing at most 6 colors so the consent clause survives the 500-char DM truncation), once per distinct overflow set per session, riding the engine.diagnostic rail into getLogs. The cap raise 4→8 is safe: slots are lazy pay-per-active (inactive groups share a 1×1 zero view) and the composite already scales by slot count.
  • Scripted-texture parking is now per-REF with retry-before-park and probation recovery (ledger 1067, half A). The 50ms bake budget is wall-clock, so scheduler starvation one-striked trivially cheap draws (a 64×64 one-gradient draw measured 288–3201ms on a busy main thread) into a park that only a source-hash change could lift — and the park keyed on the script PATH, so one faulted ?param ghost variant starved the live variant's whole parametric family. Now: a budget fault marks the ref suspect (quiet rejection; the asset service's existing retry ladder re-asks) and the park lands only on TEXTURE_BUDGET_FAULTS_TO_PARK = 2 consecutive budget faults; parks key on the full script+params ref identity; a budget park re-probes one bake per probation window (TEXTURE_PARK_PROBATION_MS = 30s, doubling per failed probe to a 5min cap — bounded, never a hot loop) and a clean probe un-parks in place, so the consumer heals without a refresh. Retry-before-park was chosen over CPU-time measurement because performance.now() around the draw is the same wall clock (no isolatable per-thread CPU clock exists in workers); a retry is evidence the budget can trust. Compile/runtime faults stay one-strike and edit-only — they are deterministic, so probing them wastes bakes. Pinned in scripted-texture.test.ts: suspect-then-park fault counts, clean-bake streak reset, ?hue=1 park leaves ?hue=2 live, probation unpark/re-park with window doubling, deterministic parks never probe.
  • Scripted-material frame-budget parks now stage back on after sustained recovery (ledger 1067, half B). recovered used to be a no-op — park state is renderer-module memory, so one transient stall (thermal dip, background tab) flattened every scripted material to Std/PBR for the rest of the session; refresh healed it. Now driveScriptedMaterialRecovery (wired into the renderer's guard tick) re-enables parked frame-budget materials ONE per sustained-healthy interval (UNPARK_STAGE_INTERVAL_MS = 15s under the guard's own 28ms degraded line, guard state ok) — never all at once, which would spike the GPU into a thundering-herd re-park. The newest re-enable stays on probation for one full interval; a GPU fallback while it holds re-parks THAT ref individually (reason probe-reoffense, edit-only from then on) and leaves the proven survivors live. Park reasons are now explicit (parkScriptedMaterials(refs, reason)): frame-budget parks are recovery-eligible, build-failure parks and probe re-offenders stay edit-only, and the parked look never auto-unparks (script-caused, not device-caused). The fallback diagnostic's body now describes the automatic staged recovery instead of naming a script edit as the only path back (consent rule and its truncation-safe position unchanged). Pinned in frame-budget-report.test.ts: one-per-interval staging with action reports, unhealthy-sample stretch reset, individual re-park + quarantine, organic fallback still full-parks, build-failure parks and the look stay parked.
  • No new spec surface or config knobs — all constants above are internal and documented at their definition; no new diagnostic codes (unpark/re-park reports ride the existing frame-budget-fallback rail with their own dedupe keys).
  • Place travel now warms the far side ahead of the server round trip (docs/place-travel-prewarm.md), through the existing preload-hint lane end to end — no new pipeline, queue, priority, or spec vocabulary. Two triggers: (1) issue-time — the moment enterPlace's client branch forwards rail.enterPlace for a locally-controlled traveler, the destination place's asset refs (objects/terrain/atmosphere refs, script literals, sprite variants; createIfMissing warms from the inline def, unioned with the base def for instanced travel onto an existing place) queue at explicit priority, the same lane api.preloadAsset uses, so fetches overlap the RTT and the veil beat instead of waiting behind them; (2) steady-state — the quiet-window preload sweep additionally emits the UNFILTERED spec snapshot's refs at idle priority after the kept place's (the applied spec is place-filtered, so other places' assets were invisible to the sweep and stone-cold at travel time). All downstream discipline is inherited, not re-implemented: warms never outrank a real load, idle guesses need boot-drained + idle-now frames, residency lives under the asset services' keep-alive byte budgets. Zero sim trace (the hint lane's determinism contract); a refused travel leaves cached bytes and nothing else; travel spam converges on the queue's ref dedupe + priority upgrade; capsule replay worlds never prewarm (the sweep's replay exclusion, mirrored — speculative warms the capsule can't serve would trip the replay's offline contract as phantom misses). Travel authority untouched — the validated server rail remains the only door. Pinned in place-travel-prewarm.test.ts (issue-time trigger + gates), client-auth-place-travel-prewarm-e2e.test.ts (overlap timeline, refused-travel abandonment, double-travel convergence over real netcode), and asset-preload-sweep.test.ts (warm-ahead leg).
  • A renderer-side boot watchdog now covers the silent never-drew lane (ledger 1163, resx's sky plate + trunk): any standalone scripted-material entity that is authored visible and in view but has produced zero main-pass draws by the post-boot sweep gets ONE getLogs-visible diagnostic naming the entity, its lane, and the stage it is wedged at (record missing / geometry empty / pipeline compiling or parked / textures unresolved) — the lume successor of the three-era renderer-object-draw-stalled rail, on the join watchdog's timing. Mechanism hardening in the same lane: a parked (permanently failed) pipeline no longer counts as compile-in-flight on the scripted primitive/model draw paths, so one bad material can't hold the frame-idle bootDrained latch — and every watchdog gated on it — hostage for the session.
  • Remote actors now carry anticipation instead of leaking the playout delay as netcode (docs/remote-anticipation-masking.md). The problem: a remote a3dc actor's locomotion weights (draw/mixer, derived on its simulator) apply at data-ARRIVAL time while the drawn body runs 1–2+ ticks behind on the playout buffer — so starts read as walk-cycle-then-slide, stops as plant-and-skate, and feed stalls as running in place. The mask re-times the envelope to the drawn body: a client system (tome/remote-actor-anticipation, the draw/presentation-delay idiom) stamps a generic draw/anticipation policy on a3dc actors this realm does NOT simulate (no speedOverride), mirroring their resolved walk/run thresholds; the renderer's transform sampler re-derives the blend from max(displayedSpeed, headSpeed) — the drawn pose's own XZ derivative (releases follow the body: stops are honest) vs the newest snapshot-pair slope faded by freshness and arrival age (onsets lead: wind-up begins at arrival, up to the full playout lead before the body launches; stalls ease to rest instead of a treadmill). Weights ride a pooled synthetic draw/locomotion-weights op (change-gated — a resting actor costs nothing) into the models store, which steers ONLY the frozen a3dc locomotion layers' target weights and re-applies the override after every wire mixer reconcile; REMOVE restores the wire weights. Rotation additionally samples up to 2 ticks ahead of position on the same ring (clamped to newest — no pose is ever invented), so the body turns into the move first. Presentation-plane only: no sim writes, no wire changes, plane: "client" / replicate: "never" policy; locally simulated actors and everything in singleplayer never carry the policy and their op streams are pinned byte-identical.
  • The a3dc speed→weights blend (thresholds, hysteresis, smoothstep bands, weight floor) moved from the locomotion feature into draw-animated-character.ts (blendLocomotionWeights + exported constants) so the simulator-side shim and the renderer-side mask speak one vocabulary — the feature's behavior is unchanged, pinned by its existing single-writer suite.
  • Remote-sourced event juice schedules on the playout clock (docs/remote-juice-playout-clock.md, stacks on the adaptive remote-view playout). Fires that arrived over the wire (StickyEventAdd.ingested — captured at emit time while a replicated ingest window is open, beginIngest(tick, { replicated: true }) at the two wire seams: replication-delta event rows and forwarded server-context writes) hold at the juice drain until the renderer-published displayedTick reaches the fire's timeline coordinate (drain tick − release lead, lead = floor depth + 1), so a hit flash and the pose it was emitted against present on the same timeline at every buffer depth — at the deepest adaptive buffer the flash no longer lands ~130 ms ahead of the body it hits. The renderer publishes displayedTick once per frame over a new seqlock SAB feedback lane (playout-feedback.ts, same render→sim family as cameraAngles/pointerRay/modelBonePoses); the drain reads it once per sim tick. Local fires never enter the hold (own-action juice stays 0 ms), dedup/sequencing runs at drain with unchanged keys and counters, audio and visual kinds release from the same held entry, and every hold is bounded — floor-depth clocks release at drain (zero deferral, today's timing), a stalled or vanished clock degrades to arrival-time dispatch after MAX_PLAYOUT_DELAY_TICKS, and a local timeline rebase voids the stamps. Zero sim impact: the hold delays handleJuiceEvent dispatch only; change log, replication, upload, and determinism are untouched.
  • The lume scripted-material lanes (static models, skinned characters, bespoke primitives) now pass the surface's own color inputs into the shared record cache as ScriptedSurfaceDefaults: when the built ShadeMaterial has no colorNode, the record synthesizes the glTF albedo chain — baseColorFactor × baseColorTexture(uv) × COLOR_0 vertex colors (bespoke meshes contribute their vertex-color leg) — exactly what the plain lanes hard-code in WGSL. Scripts that author colorNode own their appearance unchanged; an explicit overrides.color replaces the factor (copy-source-appearance precedence). Model lanes now group scripted primitives per (attribute signature × glTF material) so trunk/foliage materials never share a record, the surface identity rides the record key (an asset-generation id keeps reloads off stale texture views), and async base-color decodes flip the record's texture handle so bind groups rebind off the 1×1 placeholder.
  • Two holes in the spec-child pipeline, found live in the zoo's Farm Dusk pen (walk 07-08 — scarecrow rim + 24-firefly swarm dead in sim while the lamps worked). (1) compileSpecSteps iterated RAW place objects, so a children: [...] entry carrying behavior never registered in compiled.objects under its expanded {parent}/{child} id — and spawnObject stores only { specId } (no behaviorRefs), so resolveCompiledBehavior's lazy path had nothing to compile from. Child behaviors silently never ran on a fresh apply (no fault, no log); they only came alive if a LATER apply's rebind sweep happened to recompile them, which is why the rim worked in long-lived rooms and died on every fresh boot. The compiler now expands children through the same expandChildObjects the interpreter spawns with (extracted to tome/spec-children.ts — one id convention, both consumers), so child behaviors compile, enter the module entry-point graph, and run from tick one. (2) resolveObjectIdFromEntity gains a final fallback: a leaf id that missed every scope (self-child, mod, place, raw) resolves to the unique parented entity whose id ends with /{leafId} — the id the creator actually authored in the spec. Ambiguity (two parents both naming twin, child-N fallbacks) stays null: resolution never guesses. This unblocks the controller pattern — one script driving many objects' child lights by their authored ids (setObjectProperty('fd-fly-3-light', …)) — which previously warned entity not found and dropped every write.
  • The 2D-meta hydration lane (sprite-metadata-hydration) and windowed range fetches (fetchRangeWindowClassified) now classify failures with the same failureDetail field as the atlas fetchers — the server error body's teaching detail rides into the give-up warning instead of a bare HTTP status (completes the grok-imagine detail relay across every sprite fetch lane).
  • The tome UI planes (game HUD + creator rail) sample at 30Hz by default instead of 10Hz (DEFAULT_TARGET_FPS, worker-controller.ts) — the new default matches the default simulation rate, so JS-computed animation values in HUD html step at the rate state actually changes instead of visibly snapping (a 0.4s momentum pop rendered as ~4 discrete size jumps over a 120Hz world; dig 8a9adfa6). The FNV render-signature dedup keeps idle traffic flat: unchanged UI state posts nothing at any cadence (now pinned by test), so the added idle cost is renderFn eval + signature per sample — measured ~3.6µs on a 2KB HUD and ~38µs on a 16KB HUD, ≤ ~1.2ms/s of worker time at 30Hz. Animating UIs post up to 3× more messages, each bounded by the existing morphdom apply.
  • New creator-facing knob engine.ui.targetFps (default 30, clamped 5..30 — the ceiling matches the default sim rate, past which samples can only rebuild identical state). The controller re-reads it every sample, so api.patchEngine({ ui: { targetFps: 15 } }) reschedules live with no remount. The zod schema stays deliberately lenient (plain optional number; the engine clamps at read) so the kiln persistence gate never rejects a value the engine runs, and patchEngine's ignored-key warn rail knows the key via the compile-pinned parity lists. Deliberately NO interpolation machinery: CSS transitions/keyframes inside the rendered html remain the smoothing lane for sub-sample motion (they run on the browser clock at full display refresh).
  • Game-UI mount-key recheck rebased from 10 to 30 resolver invocations, preserving the designed ~1s staleness bound and unchanged per-second recheck cost at the new sample rate.
  • Tome UI plane <img> loads now have an error path (ledger 1087): the UI plane was the one asset consumer in the engine with no retry — renderer textures poll, audio parks and re-arms, the cdn session retries forever, but a UI image whose fetch died mid-flight stayed a broken icon for the whole session, because the morphdom pass reuses nodes whose src is unchanged (the browser never refetches a reused node) and a page refresh was the only remount (dreamcatcher's Petling Grove bestiary, dump 1ec69fd1 — two connection blips killed four in-flight variant fetches). dom-host.ts now installs one delegated capture-phase error/load listener pair on the render container (error/load don't bubble; capture still walks the ancestor chain — zero per-node listeners): a failed load gets a bounded backoff refetch (2s then 8s, plain same-url src re-set — no cache-buster, the failure mode is a transient client blip against a healthy CDN), then a terminal data-img-failed mark. Retry state rides data-retry-count on the node, carried fromEl→toEl across morphs only while src is unchanged (same shape as preservePersistingVideoSrc) so the isEqualNode fast path is preserved; a src change copies nothing — morphdom strips the marks and the new source loads and retries from a clean slate, which is also how a failed-marked image revives. A successful load clears the ledger so a later blip gets the full schedule again; dispose cancels pending retry timers.
  • Excluded from the generic retry, pinned by test: data:/blob: sources (game onerror-bootstrap imgs — retrying re-runs creator code), images carrying an authored onerror (that attribute IS the creator's failure path; the refire machinery owns re-arming those), and anything under mcdn-icon's data-icon-failed freeze (the letter-fallback chip is the icon system's own live-DOM fallback).
  • warmPriorityUiAssets' warm set stops being add-only poison: the warm Image gets an onerror that evicts the url, and a live <img> error evicts it too, so the next render re-warms instead of trusting a fetch that never landed.
  • computeCanSee (npc/perception.ts) deferred its occlusion ray to the per-pair (tick + id-hash) stagger whenever ANY NpcSense entry existed — including a stale one whose checkTick froze while the target was out of range/cone (or the behavior wasn't asking). That quantized the approach edge — "player walks into vision range", "sight line opens" — by up to a full re-ray interval (~170 ms worst / ~83 ms average at 30 Hz); for friend-lobby RTTs, larger than every network leg of the world-reacts-to-you loop combined (honest leg-by-leg budget: docs/world-reactions-offrail.md). Now only a FRESH entry (ray-confirmed within one interval, i.e. the pair is under continuous surveillance and the stagger is actively refreshing it) defers; a stale entry re-rays immediately, exactly like a missing entry (first contact already behaved this way). Steady-state ray rate is unchanged by construction — the immediate ray makes the entry fresh, so a pair still costs at most one ray per interval — and the decision stays a pure function of replicated state + tick (deterministic across simulators, host migrations, and replays). Simulator-local: zero protocol change, zero new vocabulary, zero presentation impact. Pinned in perception.test.ts: off-phase re-entry rays immediately, off-phase sight-line-reopen rays immediately, continuous surveillance keeps exactly one ray per interval.
  • docs/world-reactions-offrail.md documents why relocating crossing detection to the approaching player's client (and the "senses lease" variant) is rejected: a P-published crossing event rides the same socket, server sequencing, and fan-out as the pose upload it derives from and cannot arrive at the simulator earlier; the cone gate would read RTT-stale sensor facing instead of the simulator's 0 ms-fresh one; and NpcSense is a single-writer component on the observer, so per-target foreign writers would LWW-clobber each other. Also records the discovered adjacent gap (non-host envelope onNoise listeners are structurally deaf — noise.fwd only reaches the place host) as a named follow-up, not touched here.

KNOWN OPEN AT MINT (5.2.0 staging draft — named honestly for the zoo walk and the promote packet):

  • shade vocabulary gaps: scripts reaching for node-vocabulary symbols shade doesn't yet cover park with a diagnostic naming the missing symbol (never a broken frame); scripts within the documented Custom Materials / Looks vocabulary are unaffected. Jure is filling the gaps now (2026-07-10, his call: not a mint blocker).
  • Sprite ordering (named open at the lume merge): ordering fixes have landed since — mixed opaque/translucent bespoke meshes draw in the right order (this version) and transparency-sort receipts are pinned executable in the staging zoo (#8588) — but whole-batch-granularity ordering edge cases in dense transparent-sprite scenes remain a polish lane.
  • PBR completeness (named open at the lume merge): clustered lights now reach every material path (this version); still open — water binds placeholder normal/foam/caustic textures (water-store.ts TODO(lume-water-textures); wiring the real maps is named feature work in the renderer's asset layer).
  • The master→lume parity catalog (docs/lume-master-parity.md) carries the remaining port ledger, notably: view_live_scene burst filmstrip still lacks the look+bloom composite leg (Savi captures under-report active grading), the lume texture-leg retry ladder still runs pre-overhaul semantics (terminal tombstone parks landed this version; quota-park/jitter/dead-4xx parity pending), ghost-mesh stall sentinel pending (text-material prewarm landed — lume/text/text.ts:510, test-pinned).

PROMOTE GATE — auto-update exposure (for the promote packet; NOT resolved by this mint): games with engine_auto_update = true inherit the lume renderer sight-unseen the moment 5.2.0 promotes (tucker's exposure flag, 2026-07-03: "a lot of people auto update"). Before the draft:false flip, the promote packet must carry the auto-update-cohort story: staging soak breadth across device tiers (zoo cert walk + real staging playtime on this SHA), the staged-promote / pause-auto-update decision for this rung, and the skill/teaching-skew story in BOTH directions (games pinned back to 5.1.x resurrect era-stamped teaching; auto-updated games must get current-era skills — the skills-versioned-per-app model). The flip to draft:false is the release step and stays human-gated.

Engine v5.1.13

Released July 8, 2026

  • When your game's music code hits a wrong note — an unknown drum name, a bad pattern — Savi actually hears about it now, with a helpful nudge toward the closest real instrument. And "tabla" just works.
  • Games open behind a smooth veil instead of flashing a half-assembled world for the first second — the reveal waits until the scene has actually come together.
  • Holding Ctrl while scrolling in god mode no longer flies the camera — Ctrl+scroll is reserved for your game's own bindings (and your browser's), so a zoom binding doesn't fight the editor.
  • Box colliders on scripted meshes now sit exactly where the mesh is — an off-center shape (like a car whose body reaches forward of its pivot) no longer leaves an invisible wall behind it or lets players walk through its front.
  • Testing in your dev room no longer touches your players' real saves — dev gets its own save space (starting fresh), while your published game's saves stay exactly as they are. Shared world data (terrain edits, shops, chat) still flows from build to publish like before.
  • If god mode gets switched off in your game while someone is still flying around in it, they can now always land back into normal play — no more being stuck as a ghost until they reload.
  • The god-mode help overlay now tells you Ctrl is sprint.
  • Games can now have real, permanent leaderboards! Scores survive server restarts and rank players across every room and session — all-time high scores, fastest laps, daily bests. Time trials work too: mark a board as lowest-wins and the best time comes out on top.
  • Liquid marks (paint-a-pond terrain) no longer leave invisible ghost objects piling up on the server for every player who joins — and rooms clean up the ghosts left behind by older sessions on their own.
  • When a 3D model can't download, Savi now gets a clear "model failed to load" message naming the asset — instead of the game silently showing a placeholder all day with nothing in the logs.
  • Games that build asset paths with ${…} templates no longer try to download those half-written paths behind the scenes — the phantom fetch errors that spammed logs every session are gone.
  • AI-generated models with layered shells (hollow casings, double-walled builds) stop coming out with scorched-black patches — ambient shadows now bake from the surface you actually see.
  • Game scripts can now ask which room they're in: api.getRoomMode() returns "dev" in your build room and "live" in your published game — handy for keeping test data (scores, shop stock) separate from your players' while world data keeps flowing from build to publish.
  • When Savi looks at your game, she now sees exactly what players see — your color grading and glow included — so her verdicts on how things look are finally trustworthy.
  • Scripted materials that failed to arrive after joining a room used to silently show up as plain default surfaces forever — now the engine retries the handoff on its own and tells Savi exactly what went missing.
  • Editing your scene reuses already-built materials instead of rebuilding all of them, so edits land faster and smoother.
  • Players on slower machines no longer get trapped in a rebuild-the-world loop that ends in a disconnect — if the world falls behind, the engine recovers once and then backs off instead of thrashing.
  • When a terrain texture can't load, that layer now shows a neutral grey instead of hanging invisible or wrongly-colored — and Savi gets told exactly which layer failed and why. If the texture recovers later, the real look comes back on its own.
  • Transparent generated shapes (glass, paper lanterns, light shafts) render both sides again — translucent builds that turned inside-out (caps floating, fins splayed) snap back to their assembled look.
  • When a game hits its voice-generation limit, the engine now waits exactly as long as the server asks before trying again — instead of hammering the same request every few seconds for hours.
  • Savi can read each device's live render-quality state again (api.getClientHealth()), engine-bug reports include per-device health instead of nothing, and api.resetQualityLanding() actually resets a stuck quality landing — all three had been silently dead since mid-June.
  • Setting a rotation hinge on a character mid-game no longer makes them jump sideways — the object stays exactly where it is when a script sets or clears its pivot, and only turns around the new hinge from then on.
  • Pixel-art sprites no longer get permanently stuck broken when the network rate-limits during loading — no more whole sprite sheets squeezed into one tile, wrong-size buildings, or characters that ignore the lighting. The engine now keeps retrying politely until the art arrives.
  • Characters no longer blink invisible for a moment when turning or changing animation in 3D games with pixel-art sprites.
  • Fixed 2D characters turning invisible after walking between places — leftover 3D animation names can no longer hijack a sprite's animation and point it at an empty image.

technical notes

  • Vibe audio runtime errors reach Savi again across the worker boundary (#8349, merge 4e113a7018, ledger 1018): the error drain was structurally dead in prod — errors are born in the WebAudio renderer on the MAIN thread, but the drain consumer (AudioPrepSystem → sendClientBehaviorFault → engine-diagnostic rail) runs in the runtime worker where the renderer handle is realm-local null. Batches now relay across the existing audio seam (audio.vibeErrors, same lane as audio.completed), and prep consumes both the realm-local handle and the relayed queue. Plus: _suggestBankName speaks bank-level vocabulary (a vsco_cellos_pizz miss suggests a vsco_cellos* bank, not a per-pitch group member), and tabla aliases to tabla_na so the bare drum name resolves like bd/sn/hat.
  • TransitionVeil engages on cold boot — the raw-world first-frame flash dies (#8353, merge f6a9c8dd6a, ledger 1021): the session's first renderer frame starts in the veil's holding state, so the first present is black instead of the assembling world (assets streaming, materials compiling) that painted ~1s before the game's HTML UI could show its loading screen. Boot readiness rides sceneAssembled: first authoritative snapshot applied AND world-residency's streaming lanes quiet this frame (new un-latched quietNow verdict); at boot the flip signals are vacuously true. Same VEIL_MAX_HOLD_MS cap, same reveal fade; ECS still reflects spec instantly — only the visual reveal is held. 2D-place boots get the same dimension-agnostic engage.
  • Chorded scroll belongs to authored bindings — ctrl+scroll no longer dollies the god-mode camera (#8368, merge d4f545c617): the god-mode wheel dolly now suppresses itself when modifiers.ctrl is held, alongside the existing over-UI / coarse-pointer / brush-gesture suppressions. Bare scroll keeps the dolly.
  • Box colliders derived from bespoke-mesh bounds carry bounds.center as an optional entity-local translation on PhysicsColliderBox (ledger 1001; deriveColliderFromBounds previously dropped the center, so any off-center collider() mesh got a box snapped back to the entity origin — the iso-town car's ~1 m phantom wall behind / walk-through front). y rides the body origin via computeFeetOffset, x/z land as the collider's local shape offset in both engines (rapier setTranslation, mantle localPos); carried through sanitize, the wire codec, collider signatures, AOI radius, NPC nav/perception AABBs, the collider debug view, and trace. Not breaking: the translation is omitted whenever the bounds center equals the implicit default (x/z-centered, feet-anchored — every builtin primitive), so already-correct derivations keep identical component values and collider signatures; only previously-wrong off-center shapes move, and they move onto their visuals.
  • Dev rooms get their own player-save namespace; live saves untouched; world data still shared dev↔live (ledger #995). Dev and live are room modes of one variant and the documents API is mode-blind, so a dev-room script writing user/<uid>/save previously hit the same document as the live room — player saves alternate-clobbered whenever both containers were up. Now both storage rails (the pool-worker rail in jobs/worker-env.ts and the lifecycle rail in room-runtime's LifecycleContext, applied in object-api's fast path — mapping shared via engine/runtime/jobs/storage-namespace.ts) mode-segment PLAYER-scoped script keys only: a dev-room user/... read/write/list/lock resolves to document jobs/dev/user/..., while live/published rooms keep the bare jobs/user/... names — zero migration, every existing live save untouched; dev starts empty. World-scoped keys (everything not under user/ — terrain-edits, market/state, chat/state) stay shared across modes by design: the build→publish workflow depends on world data riding the mode-blind lane. Absent/unknown room modes fail toward the old behavior (bare names, shared with live). forwarded-storage-namespace.test.ts pins dev↔live disjointness for user/ keys, continued world-key sharing, and that both rails resolve identically.
  • God-mode exit is always allowed — the spec.godMode.enabled === false guard now gates entry only (#8341, merge ebf2c5ae96, ledger 1006). toggleGodModeForPlayer ran the spec-enabled guard before the exit branch, so a mid-session spec flip to disabled stranded an in-god creator ("God mode disabled in spec" on every exit) until disconnect/reload. Both authorities share the fix (server command + singleplayer worker call the same core); pinned in godmode-spec-update.test.ts, and the #8326 posture characterization updated to the new semantics (mid-session disable → atomic exit posture; blocked re-entry's god:error still never mutates posture).
  • God-mode controls overlay adds a "Ctrl — sprint" row to the starter shortcut legend (#8351, merge 0eeed3ef5f).
  • New builtin job leaderboard:submit — platform-durable score submission from creator scripts: objectApi.job("leaderboard:submit", { leaderboardId, score, userId, direction? }, cb). Posts server-side to kiln's SDK leaderboard submit route with the room's SDK identity (the API key lives in a worker-env capability closure — env.leaderboard, built at pool-worker spawn from workerData — and is never readable from script space, args, or results). direction: "lower" (time trials) negates the score on submit to fit the GREATEST-hardwired platform tables; scores are rounded to integers and rejected outside ±2^52. Soft-fail contract: the job DATA always resolves { ok: true, rank?, improved? } or { ok: false, error } (missing_user, user_not_in_room, invalid_score, rate_limited, …) — never throws for runtime failures, so fire-and-forget if (!result.data.ok) checks are safe.
  • Room-trust gating at the two builtin-job seams (tome/job-dispatch on the authoritative server world, tome/job-forward-server for forwarded singleplayer/client-auth submissions): the target userId must be a connected player (presence stamped into args server-side, overwriting anything script- or wire-supplied; the handler fails closed without the stamp), plus an in-room cadence cap of one submission per (userId, leaderboardId) per 2s. Server submissions also opt in to kiln's DB-side rate limit (enforceRateLimit: true).
  • Skill docs: leaderboard gains a "Durable Leaderboards (platform)" section; jobs-and-storage lists the new builtin.
  • Mark-liquid surfaces stop minting server husks (#8354, merge 0f788c664b, ledger 1020): TerrainMarkLiquidSystem (mode client) derives liquid surfaces locally on every client, but on a place host the entities satisfied isHostSimulatedEntity and published as hosted-remainder state — the server stripped the replicate-never geometry and kept a permanent geometry-less husk per surface, fanned to every joiner, with signature drift stranding old husks forever. Three walls: (1) isHostSimulatedEntity excludes engine-derived ClientEntity locals (covers the upload view, the reset stray sweep, and the rail flush in one predicate); (2) the server's upload create gate refuses terrain/mark-liquid/* rows so stale-engine hosts can't re-mint (create-only — deletes stay host-writable); (3) as live host, the system reaps stale-signature husks for mark keys it owns and publishes the deletes through the new ClientAuthHostDespawnPublishResource, re-arming on host claim between rebuilds.
  • model-load-failed joins the allowlisted engine-diagnostic codes (#8359 kernel leg, merge 043b479c3f, ledger 1025): the model sibling of texture-load-failed. Neither of the model rails covered a failing FETCH — the contentless report fires only on successful-but-empty loads and the unready-asset note rides scene-view only — so a 5xx-looping model (e.g. a 24h magic-cdn tombstone) held its "Couldn't spawn" placeholder all day while Savi's log rail stayed clean. Same emitter, same once-per-asset-per-episode cap. The kiln legs of ledger 1025 (429/502 propagation on the serving routes) ride kiln's own lane, not this kernel build.
  • asset-preload-sweep skips template placeholders (#8347, merge fd67a70350, ledger 1017): SCRIPT_ASSET_REF_PATTERN string-scans raw script source including backtick template literals, so refs like `/cdn/${SLUG}/tracks/theme.mp3` were prefetched as literal URLs (~9 phantom URLs × 3 retries × every session on the specimen app, ~311 iframe errors/2h). Any scanned ref containing ${ is now skipped — a template placeholder is by definition not a fetchable literal. Literal refs in the same source still preload.
  • Remesh GPU bake casts AO from the outside-visible sheet on nested shells (#8352, merge ef67ac807d, ledger 1013 step 1): Trellis raws carry nested same-facing onion shells, and the nearest-agreeing texel search landed on the inner enclosed sheet wherever the decimated surface drifted inward — the AO hemisphere then measured the inside of the shell stack (occlusion ~1, scorched black; the y2k/frutiger mottling class). Before the AO loop, one BVH scan walks +N to the cage-clamp ceiling and relocates the AO origin to the outermost same-facing crossing below the nearest opposing crossing; single-sheet texels keep the classic origin bit-for-bit, and albedo/normal/roughness/metal transfer is untouched. Ships in packages/remesh (the remesh worker image), not the kernel tarball — aboard for every remesh regardless of pinned engine version.
  • Scripts can read the room's mode: api.getRoomMode() returns "dev" | "live" in behaviors, lifecycle hooks, crons, and run_script. The value is mirrored from the room's SDK identity (TomeRoomModeResource, written by room-runtime from the same getRoomModeIdentity() both storage rails key their mode segmentation on — storage-namespace.ts) so the read can never disagree with where a user/ save actually lands; absent/unknown modes read as "live", exactly how modeSegmentStorageKey treats them (ledger #996, follow-on to #995).
  • Dev-room shared-key FYI for Savi: when a game script in a dev room writes (storage:set/storage:del) a world-scoped key — anything not under user/ — the engine records one informational runtime-log line (the getLogs channel) per key per room session, naming the by-design dev↔live sharing and the deliberate mode-split options (user/ keys, or api.getRoomMode() in the key) without instructing a rewrite. The hook sits on the ObjectAPI job() seam, so the lifecycle fast path and the queued/pool lane both pass through it while engine-internal storage lanes (terrain-edit persistence submits straight to the JobQueue) stay silent. Live rooms and unknown-mode worlds never warn.
  • view_live_scene camera/frame captures composite the active look + bloom (#8344, merge 32684f98c4, ledger 1009): captures previously skipped the whole post chain (no atmosphere.look grading, no bloom), so Savi graded look-dependent work against pixels the player never sees. The live post graph can't be re-driven from a capture pose (scene pass, bloom pyramid, TAAU history are main-camera-keyed and accumulate across frames), so captures run a capture-owned chain built from the same pieces: scene → linear HDR (+ capture-pose depth) → the engine's dualFilterPyramidBloom driven by the same resolved-look dials and quality-governor skip → ACES → the same LookPass class (scripted graph + vocabulary + sRGB + dither) when a look is active → the capture's output target.
  • Scripted-material library join failure gets a loud diagnostic + a join watchdog retry (#8342, merge f98c0073f3, ledger 1014): on some joins the derived MaterialScripts library (replicate-never, forward-always) never reached the renderer worker, so every scripted intent built as its Std/PBR fallback — silently (the report was gated on libraryReceived) and permanently (only a source-hash edit or renderer restart refreshed). Now: a scripted-material-library-missing diagnostic (reportOnce per ref per session, distinguishing never-received from received-empty), a renderer-local join watchdog (~10s after boot-drain latch, one re-check, never a poll loop), and the server-side engine-diagnostics dedupe gains a TTL so later joins' byte-identical reports aren't swallowed room-lifetime. Fallback behavior itself unchanged.
  • Standalone-primitive material mints resolve through a scene-scoped refcounted content-identity registry (#8315, merge 8b13fa8fa0, perf-apex L16, ledgers 900/952/959): same mint inputs → the same already-compiled material instance across wearers and across edits; changed content mints new, and a replaced identity disposes exactly when its last wearer swaps off. Per-entity-mutable mints (textured leases, draw/effect carriers, active flash, appearance-owning water/shockwave/slash) stay per-entity; patches on shared identities settle first (sole wearers re-home in place, divergers split before any uniform write can bleed).
  • The stale-path reset↔recompile strobe is capped with the bespoke branch's epoch budget + backoff (#8361, merge f78ff89b14, ledger 1026): the stale branch of prepareRenderBacklogRecovery fired writeRecoverySnapshot unbounded — on a cold-shader client the recovery snapshot's own respawn mints all-new batches/materials, the compile storm puts the renderer behind again, and the trigger re-arms from its own hangover (observed 60s→29s cycles, 379 programs on 25 draws, 691ms renderer frames, until connection death). Now: a sim-side epoch that survives the reset, first recovery immediate, then doubling backoff (30s/60s/120s…) with the strobe declared via one warn once the rapid budget (3) is spent. One deliberate divergence from the bespoke sibling: the backoff clamps at 300s instead of refusing forever (the stale reset is also the sim-side backlog's memory-relief valve), and the epoch dies after 120s of quiet so a later real staleness event starts fresh.
  • Terrain splat-layer texture fetch failures get a neutral-albedo fallback + a Savi-visible diagnostic naming the layer (#8345, merge aed167474f, ledger 1008): the layer atlas only subscribed to load successes, so a failed fetch (the staging moodboard KTX2 401 family) left the layer waiting invisibly forever and Savi never heard the layer's name. renderer-asset-service gains subscribeTextureLoadFailures (the failure twin of subscribe(), fed from the retry ladder's verdicts; 202 still-generating and non-exhausted auth parks stay silent); a SETTLED failure flips the layer's tint row to a neutral mid-grey through the authored tint pipeline (never black, never a stale full-strength tint) and reports material + texture + HTTP status through the existing allowlisted terrain-atlas fallback codes. The success subscription stays armed — a late heal re-textures the layer and restores the authored tint.
  • #8345 review follow-ups (#8348, merge a0a5aa5dba): the heal path's authored-tint restore is un-gated from the join verdict (a healed-but-unjoinable texture takes its authored tint back); the failure channel gains a settled-verdict query (getTextureLoadFailure) so an atlas built after a terminal settle still learns of it; dead-asset fix text is auth-flavored.
  • Transparent bespoke-mesh material leases default to THREE.DoubleSide, completing the one-winding-convention pairing (ledger 977; the original rescue in createMaterialLease was scoped !willRenderTransparent, which left transparent one-sided bodies — exhibit-level transparent: true or vertex-alpha-forced — rendering the flipped side of ctx-authored faces). One default for every bespoke lease now: DoubleSide unless the author sets side or the material owns its graph. Cost is three's transparent+DoubleSide two-pass (BackSide then FrontSide), which buys correct within-mesh self-ordering and lands only on the rare standalone transparent bespoke population; it also matches the batched transparent lane (always DoubleSide) and what a live opaque→translucent geometry patch already produced (applyVertexAlphaTransparency flips transparent and leaves the lease's DoubleSide in place). The alpha-split draw grouping (ledger 956) is untouched — translucent slots keep their own groups, lanes, and shadow flags byte-for-byte; only the lease side default changes.
  • Magic-cdn probe honors Retry-After per filename (#8355 kernel leg, merge e1b641342e, ledger 1023): a dialogue-heavy game that exhausted the per-app speech cap kept re-requesting capped assets ~3.2/min for hours (937 rate-limit rejections/6.5h). A 429 with a quota reason now remembers the server's own Retry-After in a per-filename tombstone (conservative 5-min fallback, 1h ceiling); re-asks during the window get the same MagicCdnFetchError with zero network round-trip, and expiry re-admits a real probe. Per-URL-identity (other assets keep serving), bounded map (512 entries, oldest-out). The kiln legs of ledger 1023 (single-designer voice claims) ride kiln's own lane, not this kernel build.
  • The engine.clientHealth snapshot producer is restored (ledger 1033; deleted by the #6891 runtime-worker rewrite, which left CLIENT_HEALTH_COMMAND a server terminus with no sender and recordLocalClientHealth with zero callers). A factory module (client-health-report.ts, the renderer-perf-rollup pattern) taps the raw quality-governor block off each renderer.perfSample, and reports on the 15s perf-rollup cadence plus a throttled edge report on rung transitions — writing the local mirror (singleplayer api.getClientHealth(), engine-bug dump health blocks) and the client command outbox (multiplayer api.getClientHealth() via the server mirror, still re-validated + rate-limited server-side). The transition-trigger allowlist widens with the kernel's GovernorTrigger vocabulary (pacing-engage/pacing-release, the #8314 pair). The quality.landing.reset client control case and the singleplayer reset transport (setClientQualityResetTransport) — deleted by the same rewrite — are re-wired, so api.resetQualityLanding() reaches the renderer again in both realms.
  • Runtime pivot writes (setProperty("pivot") / setLocalPivot) are now pose-preserving (ledger 1038). The pivot fold T(F + S·P − R·S·P)·R·S nails the pivot's fixed point at its identity-rotation world location, so a runtime set on an already-rotated entity teleported the folded pose by (I − R)·S·P that frame — and the clear snapped it back (Rabies Bear's rear-up: 1.40m lunge at yaw 180, 0.99m diagonal at yaw 90/270, collider riding the fold). The property writer now rebases the authored local position by the fold-offset delta at write time (writeLocalPivotPreservingWorldPose, tome/pivot.ts): the entity doesn't move on set or clear, and rotation changes after the write hinge at the pivot's world location at write time. The rebase reads only replicated authored state (Local*, LocalPivot, renderable bounds — never a sampled world pose), is exactly zero at identity rotation, and works identically on roots and parented children. Spec-frame surfaces keep composing the authored fold with no rebase, via the new applySpecPivot: boot + spawn (interpreter applyObjectProps, applySpawnProperties) and property patches (applyPropertyPatchToWorld — updateObjectSpec, undo/redo envelopes, where a mixed {feetPosition, pivot} patch must land exactly where it says).
  • REMINT (#8403, merge 5ad2155ea6, ledger 1050 — the 5.1.13 remint blocker): 429s are transient, not dead — sprite atlas/meta hydration un-starves. The #8274 boot pre-warm floods magic-cdn in the boot window, and the #8259/#8270 give-up ladders classified any 4xx as a dead asset (status >= 400 && status < 500), so a staging 429 storm latched permanent give-up after 8–10 attempts: sprite atlas metadata starved (resolveSpriteUv → UNIT_UV — a whole sheet rendered into one quad), ?normals= companions starved (unlit lit-sprites), and spawn.meta world-height starved (default-size sprites). Dead is now isDeadAssetHttpStatus (4xx except 429/408/425) in both give-up lanes (renderer texture/model retries + sprite metadata hydration); transient statuses retry indefinitely like 5xx/network, honoring the server's Retry-After (the #8355 park doctrine). The renderer's atlas-hydration lane carries HTTP status through the meta fetchers instead of folding every failure to null — give-up latches only when EVERY failed source answered a dead 4xx, and served-but-no-meta settles authoritatively in one probe. A KTX2 kvd-block fetch failure is a retryable failure now, not a silent header-only fallback that latched fps=12 forever.
  • The #8277 auto-size gate, narrowed to its stated purpose: an authored collider2d no longer pins an UNSIZED sprite to [1,1] (Village Green's t2-cottage). Authored sprite.size still beats hydrated size (art and hull stay glued); the auto-anchor gate (#6713) is untouched.
  • 3D-place batched sprites no longer blink invisible on facing/clip texture swaps (ledger 1041, tiger's iso-town walk trace). Texture identity is the batch key, so a ?facing=X&animated=CLIP swap re-keys the visual across batches — and the re-key hold ghost used to release when the destination's COMPILE settled, while a destination batch still waiting on its texture lease is mesh-wide hidden by the anti-flash gate and the async compiler drops invisible objects at drain time without compiling them: "no compile pending" released the ghost into a batch that had never drawn, and the sprite rendered nothing for the fetch/transcode window (~230ms+ in the trace). The hold now arms and releases on PRESENTABILITY (spriteBatchCanPresent: texture lease bound or the mesh already past the visibility gate, plus compile actually settled — the release probe re-queues dropped compiles until the first visible pack lets them land).
  • The recurrence engine dies with it: the idle-dispose sweep (ageIdleSpriteBatches) now spares empty batches whose texture BASE (spriteTextureBaseOf — the URL before its variant query) is worn by a live visual, so a walk-kit hero's 16 facing/clip variant batches stay warm (compiled material + bound lease) once seen instead of re-fetching and re-transcoding the KTX2 on every 5s-idle revisit. The whole family disposes on the sweep after its last wearer despawns. The standalone (ySort) lane and 2D pens are untouched.
  • Sprite mixer selection never composes a non-storyboard clip name into a ?animated= variant URL anymore (ledger 1047, the zoo 2d-side invisible player). A place transition can strand stale 3D locomotion channels (idle: "Idle" at weight 1 — the a3dc vocabulary) in a sprite hero's mixer when the skin script's one-shot setProperty('mixer', …) replace is lost across the transition write-window; the stale channel won selection, composed hero.png?facing=right&animated=Idle-loop, and magic-cdn answers such URLs with a renders-nothing placeholder — an invisible player that only a refresh (fresh onSpawn skin derivation) healed. The composability rule is shared (isStoryboardClipSlug in sprite-appearance-rules — lowercase kebab slugs, the 2d-mode skill's taught vocabulary): selection skips non-slug channels (the next declared 2D clip wins; all-invalid mixers hold the visible base frame), sprite clip timing reports the nominal ~1.3s fallback for them immediately (no bogus hydration fetches, and no permanent duration: null — getChannel().finished keeps firing on wall-clock and atlas probes read the taught failed-cook signal), and the warm derivation stops minting their variants (?animated=Idle-loop warm refs were real: the scraper crossed a script's 3D-branch clip literals with its sprite textures). The skip teaches through the Savi-visible diagnostic rail (sprite-clip-not-storyboard, once per entity+clip — getLogs + one DM, like sprite-atlas-missing) plus one console warn per entity, same lane as the blank-clip skip.

Engine v5.1.12

Released July 7, 2026

  • 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.

Engine v5.1.11

Released July 6, 2026

  • Savi can now invent animations for your characters — a swim stroke, a dance, a wing-flap — by writing them as motion code, no animation files needed. They play (and blend) just like regular animations, and edits take effect at the next loop so nothing pops mid-motion.
  • Savi can now watch your game in motion, not just look at frozen screenshots — she grabs a short filmstrip of live frames, so she can actually judge whether the walk cycle slides, the jump feels floaty, or the boss swing lands on beat.
  • Game managers can now be read from anywhere! Add replicate: "world" to a manager object (like your game's phase or score tracker) and every player can see its state no matter which area they're in — no more copying state onto each player to work around it.
  • Scripted splines work again: paths that sweep a custom cross-section you describe in a script (walls, tubes, ribbons — any shape) now save properly instead of being rejected. Scripts can also scatter objects along the path — lamp posts down a walkway, pillars under a bridge — and they'll appear right where the path runs.
  • Effects no longer silently switch to a lower-quality renderer under load — the engine fits many more effects at once (128 default-sized effects on desktop, up from ~12–16), and anything truly beyond budget waits its turn instead of changing how it looks. When an effect does have to wait or shrink, the engine now says so instead of staying silent.
  • Fixed a bug where a big world could get stuck blinking — the whole scene vanishing and popping back every few seconds, forever. If a piece of scenery ever fails to reach a player now, the world stays put and the engine reports the problem instead of strobing (that one piece may need a reload to appear).
  • Fixed a leak that could slowly fill up your graphics card during long sessions in big worlds — especially ones that rebuild the scene a lot. No more "my GPU crashed" after a long build session.
  • Animated characters no longer show up pitch black in softly-lit scenes. If your character model looked fine near a lamp but turned into a silhouette everywhere else, that's fixed.
  • Feet touch the ground: sprites with empty space baked below the art (most generated side-view creatures and props) used to float above the floor — now they stand exactly on it, and their colliders line up with what you see.
  • Your ambient light now does what you asked: worlds that set the ambient color and brightness in the common flat form used to silently render a dim gray instead. Existing dark 2D worlds get the brightness their creator originally wrote as soon as they update to this engine version.
  • Cameras tuned with a small smoothing value (like 0.15) now follow the way you meant — snappy, not floating seconds behind the player. The engine recognizes the common "fraction per frame" style and applies the matching follow speed.
  • Generated shapes now render both sides — backwards-facing surfaces stop going invisible. When Savi writes procedural geometry (roofs, furniture, terrain details), a face wound the "wrong" way used to vanish when viewed from the intended side; now it just renders.
  • Name tags sit where they belong on resized characters: giants no longer wear their names inside their heads, and tiny characters no longer have names hovering way above them.
  • Rigs and builds made of many custom-geometry panels that share one material script (like skeletal character rigs) no longer freeze the game for seconds on first spawn — all panels now share one compiled material, each still rendering its own colors and atlas regions.
  • Scripts that return odd values no longer lose their changes: if Savi's script ended by returning something that couldn't travel back in the reply (a live logs handle, a function), the engine used to throw away everything the script had built — "my changes keep disappearing." Now the changes land and only the return value is replaced with a note explaining what to return instead.
  • Savi's script changes stop vanishing over reply problems, part two: a script that successfully changed your game but returned a value the reply couldn't carry (a circular structure, a BigInt) used to have the whole answer thrown away — Savi was told her changes failed, rebuilt the same thing by hand, and the saved game never got the changes. Now the changes always land and save; only the unreportable return value is replaced with a note telling Savi what happened.
  • Editing scripts while your game is running is much more reliable now: event handlers always pick up your latest code — no more "my fix doesn't work until I restart the room", and no more handlers firing twice (or four times) after repeated edits.
  • Live script edits can no longer leave an object secretly deaf to events: if an edit orphans an object's old event handlers, the object now tells you loudly — naming the event and the script, with the fix (re-save the script; if it stays deaf, restart rooms) — instead of your correct fix looking like a no-op.
  • When a script accidentally uses the wrong import/export style, the error now explains exactly what to change instead of a cryptic one-liner — so Savi fixes it on the first try instead of guessing.
  • World thumbnails and Savi's screenshots recover cleanly when the graphics engine stalls, instead of silently hanging forever.
  • Your world's shelf image refreshes while you build — the picture on your worlds list updates every few minutes during a live session instead of freezing at the first screenshot.
  • Savi learned from this week's play sessions: she now fully deletes things you ask her to remove (no invisible leftovers), makes "hesitant" NPCs pause to think instead of moving in slow motion, wires up saving correctly for spawned objects, and has worked examples on tap for combat waves, enemy AI, and damage zones.
  • Enemies and props whose 3D models are still generating no longer get reported as permanently broken: the engine used to log hard "extraction error" failures while a model was simply mid-cook (so Savi and dashboards read your placeholder pills as abandoned), and now it correctly treats "still generating" as waiting — it keeps checking on schedule and the real model swaps in the moment generation finishes.
  • Fixed a chronic multiplayer glitch family: after a reconnect or a shaky connection, players' body parts and other respawning objects could silently stop syncing to everyone else (the server kept rejecting them as "already exists"). They now heal in place automatically.
  • Building in god mode no longer leaks editor gizmos into the shared world — friends will stop seeing stray rings and handles hovering around objects you were editing.
  • Fixed the worst "my game is frozen for one player" bug: on a slow or congested connection, the server could drop the big world-snapshot message without noticing, and that player's world stopped updating forever — joins that never load, props that never spawn, refreshes that don't help — while everything looked connected. Both sides now notice within seconds and resend a fresh snapshot, so the worst case is a brief hiccup instead of a permanently frozen world.
  • Sky clouds are back: worlds using the realistic sky's built-in cloud layer have been rendering an empty sky since engine 5.1.9 — the settings were right and saved all along; the engine was quietly throwing away the clouds' first paint while their shader warmed up, then never repainting. It now retries until the paint actually lands, so your clouds show up seconds into the session, every session. Night skies get the same fix for the milky way.

technical notes

  • Authored ambient light is honored in both spellings: the runtime read only the schema-nested atmosphere.ambient.light.{color,intensity}, but the recurring authored shape is flat (ambient: {color,intensity}) — the truthy object spawned the light while both fields read undefined, silently rendering gray 0xd0d0d0 @ 0.5 instead of the authored values (two independently authored dark 2D games in one night: 0.9 × pale lavender and 0.6 × violet both shipped as 0.5 gray). resolveAmbientLightDef (atmosphere-sky-utils) resolves both shapes, nested winning per field, at both read sites (atmosphere-sync light spawn, explicit-intensity collection for the darkness convention). A once-per-room [spec-shape] warn (runtime log + DM, spec-shape-warnings.ts) keeps the drift visible when the flat spelling is used.
  • Side-scroller camera smoothing gained a unit-guard: the field is an exponential follow rate in s^-1 (default 5), but the universal gamedev prior is the per-frame lerp alpha — a value in (0,1), which read as a rate gives a multi-second time constant ("camera follows me super slowly": 0.15 → τ = 6.7s). Values in (0,1) are never a sane rate and always a plausible alpha, so resolveSmoothingRate converts them as a 60Hz alpha (rate = −ln(1−alpha)·60; 0.15 → ≈9.75 s^-1, within 3% of the skill example's 10 — the authored intent is recoverable exactly). Rates ≥ 1 and the ≤ 0 snap convention pass through untouched. A once-per-room [spec-shape] warn names the conversion. Not a 5.1.10 camera-attach regression: rate semantics are unchanged since #6713; the top-down camera shares the same rate vocabulary and is a candidate for the same guard as a follow-up.
  • Sprite metadata hydration consumes anchor_uv (+ view) from MCDN 2d metadata: unanchored side-view sprites get anchor: [0.5, 1 − anchor_uv.y] written server-side (replicated inline like the hydrated size, same push-classified prediction posture), putting the declared foot line at the entity's feet. Foot anchors only apply in the lower half of the image (anchor y-up < 0.5 — floating/centered subjects like bats keep the engine default) and only for non-topdown views; authored anchors beat hydrated ones with the same bookkeeping as auto sizes (revert on texture swap, camera-attach carve-out), and so do authored colliders: any body not carrying the autoCollider: "sprite" marker (collider2d spriteHull, explicit shapes) skips anchor hydration entirely — those hulls are a pure function of the spec anchor and are never rebuilt from hydrated state, so moving only the art would sink exactly the worlds that already fixed floating with an authored hull. An anchor hydrated before a collider became authored reverts on the next pass. The hull collider upgrade maps through the resolved anchor in the same pass, so the visible art and its convex hull move together — closing the tiger-corpus "collider of ground is above the ground" class, where collider math was exonerated to the pixel and the float was pure canvas padding (fox 29.3%, stump 19.1%).
  • Fixed the 5.1.10 whole-world blink loop: the #818 bespoke-geometry heal escalation (stream reset + fresh snapshot) had no retry bound, and each reset erased the renderer-side attempt ordinal it was gated on (the reset's despawns resolve the wait tracker's entries), so a wait whose geometry the recovery snapshot also failed to land re-armed the collapse every ~15s forever — on a large creator world the entire scene despawned and rebuilt on a loop indefinitely. The loop is also a GPU killer: each rebuild cycle leaks ~6–8 textures/programs/materials (ledger #878, the separable disposal-hygiene fix), so ~150 uncapped cycles crashed the graphics device in ~39 minutes; capped at 3 the leak is ~20 resources — unreachable as a crash. Escalation is now bounded by a sim-side epoch that survives the resets: at most 3 recovery snapshots per epoch with doubling backoff (~15s, +30s, +60s), then the heal stops re-resetting and surfaces a structured fault (bespoke-geometry-heal-exhausted) on the engine-diagnostic rail — runtime log (getLogs), a one-time DM, and a server-console breadcrumb so the class is observable from dashboards (every prior signal in this loop was browser-side only). Plain live-value re-forwards stay first-line between and after collapses; an epoch decays after 120s of request silence, so later independent drops get a fresh heal budget.
  • The renderer-host captureScreenshot() RPC gains the 15s timeout + pending-entry eviction its siblings already have (ledger 862, #8025). It was the one pending-map RPC in browser-init.ts with no timeout: a wedged render worker (e.g. a software-WebGPU adapter that boots to 'ready' but stalls the compositor) silently deadlocked every caller awaiting a screenshot — the seo-thumbnail, chat-screenshot, and savi-notes capturers. Now matches the sibling pattern (renderObjectPreview / captureSceneView): 15s timeout, evict, resolve null — the failure value all three callers already handle. No worker-side cancel lane needed: screenshot requests batch-capture the live frame, so a stale queued id costs nothing and a late result no-ops.
  • Behavior and lib-module compile failures from residual ES-module syntax now teach the two-realm rule instead of surfacing the raw parser message (ledger 876, #8074). describeEsModuleSyntaxError existed but was wired only into the terrain/geometry/spline generator paths; it's now in the compileModule and compileBehavior catch blocks, so 'Unexpected keyword export' arrives with the fix: lib/ modules are CommonJS (module.exports); behavior scripts may export hook functions but not export lists. The detector positive-matches V8's exact module-syntax messages (not substring greps), so a runtime JSON.parse failure quoting the word 'export' can never be mislabeled as an ES-module error. Generator messages stay byte-identical. Error-message channel only — no compile semantics, module resolution, or cache changes.
  • require() failures name their probe path (#8081, message text only): 'Module not compiled' now appends the probe list the resolver walked (mods/{mod}/lib/…, {mod}/lib/…, lib/…), truncated at the key it settled on, so the reader sees which probe should have hit without excavating the resolver; the runtime require error names the same namespace list as the scan error (builtin/*, lib/*, mods/*/lib/*). Resolution behavior unchanged.
  • fx-gpu arena reservation is now an ABSOLUTE per-population default (FX_GPU_DEFAULT_MAX_PARTICLES = 8,192) instead of a 1/16-arena share bounded by a quarter of remaining free slots. The old free-share rule made exhaustion a reservation artifact: ~12–16 defaulted populations reserved ANY arena size to zero while almost nothing was alive (the toybox specimen: 12 effects → 786k of the 1M desktop arena reserved, authored-3000 requests halved to 1536/384 and then refused). Desktop (1M slots) now holds 128 concurrent defaulted populations, mobile (256k) 32; authored maxParticles keeps winning over the default; tier capacities unchanged.
  • Genuine arena exhaustion (nothing evictable — everything resident is hot) now REFUSES the newest effect (arrival order) instead of silently rerouting it to the CPU particle renderer. The refused effect's entity stays valid, holds no arena allocations, renders nothing, and retries on every sync — plus immediately when a finished effect frees slots mid-frame — claiming space in arrival order. Nothing already alive is ever culled (the #7630 eviction invariant extends to refusal). The gpuFxAvailability: "unavailable" device-loss branch is untouched; capability-bound routing (ribbon sinks, path sources, encoder caps) still lands on the CPU backend.
  • The arena-pressure tell (ledger #848): refusals (fx-arena-refused), arena-tight clamps (fx-arena-pressure), and surviving capability CPU fallbacks (fx-cpu-fallback) now ride the engine-diagnostic bridge — the renderer-worker → page → parent path that reaches Datadog ([renderer]-prefixed console re-emits in browser-init, forwarded by client-error-forwarding) and Savi's getLogs + render-category DM pointer. One structured line each (population id, reason, sizes), rate-limited to 10 per room session at the emitter with a matching page-side budget. Previously these were renderer-worker console.warns that reached nothing (0 prod events in 7 days while local digs showed 12+ per room).
  • GpuFxStats gains refused (entities + reasons) and arena.refusedEffects; arena.exhaustionFallbacks is renamed exhaustionRefusals (it counts refusals now, not fallbacks). scripts/verify-fx-gpu-coupling's arena-pressure scenario pins the new refusal contract on a real WebGPU device.
  • Fixed a GPU resource leak on entity despawn: the renderer's teardown (disposeObjectTree in three/entity-object.ts) disposed geometry and materials but stranded every texture bound to the material's slots (map, normalMap, roughnessMap, …). Every full scene rebuild — stale-backlog stream resets, bespoke-geometry heal escalations, place transitions, script-driven despawn/respawn — leaked the despawned entities' texture sets until the graphics device was lost (production dump: 1224 live GL textures / 210MB texMem on a 145-draw scene after ~150 reset cycles). Despawn now disposes owned texture slots; shared instances (asset-service cache, cached-model scenes, placeholder singletons) are ownership-marked at mint (materials/texture-ownership.ts) and skipped, so refcounted caches stay authoritative over their own lifetimes.
  • Verified and pinned with a residency regression test that the stream-reset row ("despawn whatever the reader holds") routes through the same disposal path as ordinary despawns, and that reset-survivor entities still reattach warm resources without a dispose-and-rebuild.
  • Motion eye (#7977): view_live_scene gains a burst param ({frames?, spanSeconds?}, defaults 4 frames / 1.5s) — N tiles of the LIVE scene captured at deterministic playout-tick offsets and composited into ONE image strip with per-tile tick stamps burned in. Grids quantize to multiples of the tick spacing, so two bursts taken at arbitrary times stay comparable frame-for-frame. A burst is one entry in the existing scene-view lane: viewport bursts grab the canvas bitmap (2D ortho games too); camera/frame bursts re-render from a pose locked at burst start with visibility re-collected. Wedge-abandon is skipped while a burst progresses (own stall deadline); timeline rebase aborts truthfully; overlapping bursts reject at enqueue; host-timeout cancel stops an in-flight burst. Strips skip the 768px downsize, encode at built size with a caption bar, quality-stepped under the ~1MiB WS message cap. Bursts fail loud with no client — motion never degrades to a stale still.
  • Nameplate/text anchor offsets now stretch with entity scale (#8051). The draw/text offset was applied rotated but unscaled — one world-unit constant for every body size, so scaled-up characters wore nameplates inside their heads and scaled-down ones carried them mid-body or floating. Text anchors now compose P + R·(S∘offset) — the exact composition model.offset already uses and the frame world-bounds already assumed. Scale (1,1,1) reproduces the authored offset exactly, so default-size characters render identically; glyph size stays in world units (readable at any body scale) — only the anchor offset stretches. Zoo objects place gains a nameplate × scale exhibit row (½×/1×/2× capsules, same offset).
  • Loud fault floor for orphaned event subscriptions (ledger 826, plan p-64fd35d9 piece 1, stage 1 of 2): every script-edit landing path (applySpec's changed-script sweep, the client-auth fold lane) bumps a per-ref edit generation and marks the subs the old script version wired as stale; covered classes tear those down and re-register in the same apply. At dispatch, an entity whose name-matching subs for an event are entirely stale-generation (judged before the place filter — the incident's deaf ears were place-filtered stale subs) faults once per (entity, scriptRef) through the existing behavior-fault machinery, with re-save/restart cure guidance in the message. Zero added steady-state cost: all bookkeeping is edit-time. The behavior-error DM cap was raised 240 → 500 (matching the engine.diagnostic rail, ledger #235 leg 2) so the cure tail survives the DM's hook/script/entity prefix. Pairs with the stage-2 rebind sweep changeset — both are intended to ride the same release train.
  • Unified live-edit rebind sweep (rebindEditedBehaviors in interpreter.ts): one pass over all live TomeBehaviorRef entities replaces the three class-gated re-run blocks (updateObject's behaviorChanged branch, the player-diff playerOnSpawn re-run, the dynamic hot-reload loop + rerunOnSpawn). Closes the entity classes the old blocks missed (ledger 826 / THE TAPE incident): legacy row-backed entities without behaviorRefs, player handler/timer stacking, behavior-removed defs, and preserved-stale compiled modules.
  • Fold-lane coverage: foldSpecMutationsIntoWorld with script-content changes now schedules a real applySpec of the folded head (previously folded setScript left every live consumer on the old module until restart).
  • Teardown records subscription tombstones (TomeSubscriptionTombstonesResource), cleared on re-registration and reaped on entity destroy + world reset (id reuse must not inherit them). Event dispatch faults an emit whose name-match for a tombstoned entity is EMPTY — the loud, class-truthful end state for subs wired outside onSpawn behind persisted guard state (a plain re-save does not re-arm them because the guard persists).
  • Dangling runSchedule entries whose timers all fired are reaped on rebind (reapDanglingSchedules).
  • __spawnCollectRendererDebugSnapshot accepts snapshot options (#8080, apex client debug tools piece W1C): WorkerDebugSnapshotOptions threads through both collector install sites (pre-mount entry + worker-browser-host) to getWorkerDebugSnapshot, so an automated browser can request option-gated blocks ({ includePerformanceProfile: true } etc.) programmatically instead of scraping the F2 inspector's lastSnapshot. Options only select what the snapshot INCLUDES — arming stays on the inspector/worker-message path; optionless calls are byte-identical to before (pinned by test). Debug surface only.
  • New opt-in replicate?: "place" | "world" on ObjectDef/ObjectSpec (default "place" = previous behavior; absent field = absent behavior). replicate: "world" makes a designated object (global managers: game phase, scores, wave state) readable from every place: the TomeWorldReplicated marker is synced from the def on spec apply and api.spawn; the server egress fill (addGlobalAoiEntities) adds flagged entities to every AOI bucket's visible set alongside the existing hardcoded global set; and the client spec filter (filterSpecForPlace) keeps flagged defs in every place so the applySpec reconcile no longer destroys the local replica on a place transition out of the manager's place. READ path only — the owning place's simulator still solely runs the object's behavior (no execution gate reads the flag). Reads of a replicate: "world" object are eventually consistent — a client sees the owning simulator's write after one replication hop (simulator → server apply, then the server's next tick's delta fan-out), typically ~1 network round-trip + 1 server tick behind; read-after-write is guaranteed only on the machine that simulates the object, and reads go null while the object's place is unloaded — put global managers in the default place, which never unloads. Zero flagged entities = byte-identical visible sets; a log-only soft cap (WORLD_REPLICATED_SOFT_CAP = 16) warns on excessive fan-out without clamping.
  • run_script replies degrade piecewise at the structured-clone boundary instead of discarding the staged mutation batch (ledger #835, P1 data-loss class). Previously, when an exec's reply defeated structured clone — a script returning the getLogs() proxy, a function, a live handle — worker-main.ts/live-port.ts replaced the ENTIRE response with ok:false, mutationCount:0, rolling back world changes that had staged successfully (24 hits/4h on one app; 15+ apps fleet-wide in one window). The new exec/clone-fallback.ts serializes the TransactionLog and the return payload independently: the batch crosses and merges, the return value alone is replaced with a note naming the offending shape ("value omitted; your world changes WERE applied"), and tome.exec.result_not_serializable keeps the class warn-visible host-side with changesApplied truth. Preserved semantics: a THROWING script still rolls back (transactional failure ≠ cosmetic reply failure); a TransactionLog that itself defeats clone stays an honest changes-NOT-applied error; an undeliverable degraded reply falls back to an all-scalar verdict. Mirrors the decoupling the JSON wire boundary already had (sanitizeScriptResultForWire).
  • run_script replies now degrade piecewise at the kernel's JSON boundaries too, closing the remainder of the ledger #835/#841 data-loss class. The clone-fallback fix covered the structured-clone transports, but a return value that survives structured clone can still defeat JSON.stringify (circular refs, BigInt): the container exec route's reply serialization threw, the catch answered 500 exec_failed with the mutation batch dropped, and studio-chat — told failure — never persisted the batch to kiln while the live world kept the merged changes (told-failure stopped meaning not-applied). The route now passes results through sanitizeScriptResultForWire (the same sanitizer the singleplayer wire already used): verdict, mutations, and logs always cross; only the return value degrades to a note. The runtime-worker RPC reply got the same decoupling (container/tome-exec-reply.ts) — a reply-only postMessage failure no longer reports an applied exec as ok:false with the batch dropped.
  • Script-authored skeletal animation clips (#7986, p-d4228e95): a named function of time plays wherever a clip name goes. updateChannel samples it once (fn.duration × fn.fps) into keyframe tracks of rest-pose offsets, parks them in the spec (assets.authoredClips, identity <scriptRef>#<fnName>), derives a forwarded AuthoredClips component (material-scripts pattern), and the renderer composes them per rig into real THREE.AnimationClips beside GLB clips — no per-frame creator JS. Lib exports keep their module identity via a compiler WeakMap; anonymous/inferred names reject loudly; raw keyframe tracks are accepted at the same play site (tracks#<name>). Edits re-sample under the same identity, and playing layers swap at their next loop boundary without resetting channel phase. Binding contract reports through the diagnostics rail (3 new codes: unbound tracks, non-joint targets, net-drifting Hips translation); skinless models no-op with a diagnostic. Horde gate: authored identities are complex mixers (clone path only). Zoo: authored-clip swimmer station in the IK zone.
  • Fast-follow (#7996): per-identity mint-velocity guard in the authored-clip fold — a function whose sampled output changes every call re-mints per tick (per-tick spec writes + full-spec re-replication + patchAssets churn); warns once per episode through the diagnostics rail, silent on identical-hash plays and genuine edit cadence.
  • Skill teaching (#7998): the animation skill teaches clip-function authoring and the traps that make clip functions look right first try.
  • Structural lease keying for bespoke-mesh scripted materials (ledger 879). The mesh handler's material RefStore leased by FULL material value, so N custom-geometry panels wearing one script with per-panel uniform params (atlas-region fences, tints) minted N material instances — N synchronous TSL→WGSL builds at ~50–90ms each. The victim shape (a 69-panel skeletal rig baked by rig-bake.js + material-atlas-region.js) froze the render worker 3–6s on every first spawn, every re-spawn past the 2-minute idle retention, and every join with the rig saved in-world.
  • Scripted leases now share by structural family (script ref + live source hash + overrides + geometry traits — everything except the script's param values). A candidate value rides an existing variant only when its param KEY SET matches and every differing entry passes canApplyScriptedMaterialParams — the scripted runtime's own build-derived classifier. Params the build baked (plain-JS consumption, structural reads, strings, falsy-at-build) split into their own lease: values that change codegen never share, so wrong-sharing is impossible by construction. 69 uniform-only panels → 1 material instance → 1 node build (~2 with an additive lane).
  • Per-mesh uniform values ride a per-object param bag (setScriptedMaterialObjectParams): registered param uniforms carry an onObjectUpdate callback reading the render object's bag, and three clones objectGroup bindings per RenderObject — one compiled material renders every panel with its own values. Objects without a bag (primitives, previews, warm meshes, model clones) are untouched: the callback returns undefined and the material-level value keeps applying.
  • Scripted param ticks no longer re-home/clone leases (the shareKey stays pinned to the built params); changing a value's param key set now rebuilds instead of silently inheriting the stale uniform of a removed key. Store retention now keeps a whole rig warm as ~2 idle leases instead of 69, so despawn/respawn and stream-reset recovery reattach without recompiling.
  • Spline kind:"scripted" mints, spawns, and persists again (ledger 855). The engine grew the kind in 2026-05 (#6496 — types.ts, scripted-spline module, custom-geometry skill) but @spawn/tome-schemas never learned it, so the kiln persistence gate's in-area rule rejected every spawn/setProperty carrying one — authored-YES, runtime-NO on every installable engine, 0-in-11,052 corpus usage (breakage, not disuse). SplineKindSchema now includes "scripted" and SplineSpecSchema carries script/params/seed to match the engine SplineSpec.
  • Scripted-spline children(ctx) is wired into the lowering: expandSplineObjects now distributes the returned ObjectSpecs along the path (ids namespaced ${owner.id}__spline__…, tagged spline-generated, owner realm/audience/lifetime inherited — the standard generated-child lifecycle). The frames children(ctx) reads are world-space, so positions computed from frame.point are valid feetPosition values as-is. A profile() that lofts no mesh no longer gates children; a scripted spline may define profile(), geometry(), or children() (compile requirement widened accordingly).
  • Skills teaching wave — settled clauses and woken examples across the skill corpus:
    • Four savi-quality clauses (#7988): cut = delete the source (flags/zeroes/kind:off leave corpses); deliberate/hesitant NPCs = decision cadence, never movement-speed nerfs; persistence routes by entity class (terrain/voxel edits auto-persist on persistent places; runtime-spawned entities are the class needing explicit wiring); camera felt-language routes to the builtin sensitivity knob.
    • Sprite auto-flip exact contract + probe-survey playbook for atlas recooks (#8014): the one flip the engine owns (single clip named exactly walk/run/idle auto-mirrors to travel direction), and hidden-probe getChannel duration polling to verify a recook landed per facing.
    • 2d-mode skill (#7960, tiger corpus P1+P2): a light the character carries is a point light parented to the player (mood lives in the world; readability rides the player); far strips are sized to the window, not the world, with the feet anchor below the LOWEST floor so no dip opens a backdrop gap.
    • Six dormant bare-@skill examples woken into live skill routing (#7995, #7999): wave-manager, arena-player, damage-zone → combat; enemy-ai → npc; mountain + river terrain → heightmap-terrain. Bare @skill is now a generation error naming the file, const, and working grammar, so examples can't silently sleep again.
    • patchState/patchObjectState docs teach the deep-merge law + the real delete idiom (#7953): top-level undefined deletes; nested undefined assigns (the key survives); the real nested delete is delete getState().inv.sword through the tracked proxy. Two kernel tests pin the semantics.
  • Fixed skinned GLBs rendering black in ambient-only lit scenes (ledger 875, #8070). Three's WebGPU codegen drops the ambient+hemisphere contribution for SkinnedMesh draws whose material has vertexColors enabled, and every Meshy-style skinned export carries a solid-white COLOR_0 — so characters rendered black wherever the scene had no direct light. Fix at the per-character clone site (prepareMutableSkinnedTree): when a SkinnedMesh geometry's COLOR_0 is uniform/near-uniform white — or absent — vertex colors are disabled on the cloned material via the loader's existing sanitize vocabulary (disableVertexColorsOnMaterial). Lossless for the hunted class; genuinely tinted skinned characters keep their vertex colors; the loaded source asset is never mutated.
  • Stale-client diagnosability (#8058), debug + CI surface only — no renderer behavior changes. The F2 inspector's Client section gains the engine hash (play-origin pathname), the persisted adaptive-quality landing read side-effect-free (rungId, boot cuts, lease age, fingerprint verdict), the boot-APPLIED landing recorded at the browser-init load site (applied-vs-stored), and a quality-reset button routing through the existing RendererHandle.resetAdaptiveQuality entry via the inspector dispatch channel.
  • Publish guard against hash-prefix overwrite: refuse-client-prefix-overwrite.sh does one head-object existence check before any client-bytes upload and hard-refuses naming the hash (client bytes are addressed by the server tarball's hash — overwriting poisons immutable caches). Fail-closed: only a confirmed 404/NotFound passes; network errors/throttles/bad creds refuse distinctly. --force-republish warns and continues for byte-identical retries.
  • One winding convention for savi-authored geometry (jacob's call, 07-04; #7975 review follow-through). Deleted the emission-time index reversal in ctx.tri/ctx.quad (tome/geometry-context.ts) — the scripted path silently had the OPPOSITE visible side from raw kind:"custom" buffers because the reversal canceled the renderer's uniform CW→CCW flip (buildBespokeGeometry), which stays and now applies one convention to every bespoke source. Paired with it, opaque bespoke-mesh material leases default to THREE.DoubleSide (renderer/three/meshes/index.ts createMaterialLease), matching the batched bespoke lanes (always DoubleSide) and making the convention change visibility-adding rather than visibility-flipping. Scoped to opaque: transparent + DoubleSide + !forceSinglePass makes three render two passes (BackSide + FrontSide, 2x draws), so transparent meshes — explicit or via ctx.color vertex alpha — keep FrontSide; an authored side override always wins. Measured at 162-prop village density (measurement 6c884bb2): cost below noise floor, pipelines/draws identical (side is baked into the shader cache key — the default swaps which pipeline compiles, never adds one), shadow side follows and stays invisible.
  • The seo-preview capturer re-shoots every 4 minutes while a session stays live (visible tab only), instead of once per page session — world shelf images now track the build as it happens. Re-captures reuse the stable seo-thumbnail clip id so kiln's clips/register upsert (variant_id, clip_id, kind) replaces the variant's one seo-preview row instead of inserting a timestamped row per shot; register bumps created_at on re-register so newest-clip consumers surface the refresh. Each re-capture runs the existing asset-quiescence gate and dark-frame retry; a re-capture that stays dark after retries keeps the previous good shot.
  • glb-bounds jobs classify MagicCDN "202: still generating" as a DEFERRED outcome instead of a job execution error (ledger 887, #8095). The deferral machinery was already correct (the job tags httpStatus: 202 and BoundsPrefetchFeature re-asks on the exponential→slow cadence until the asset lands — ledger 643); the bug was classification at the harness boundary: the outcome vocabulary was done/error only, so every probe against a cooking model error-logged a full BoundsExtractError stack with retriable:false — an expected wait dressed as permanent failure (13× in 5 minutes on one prod app, reading in DD as bounds extraction abandoned while the enemy models were simply mid-generation; creator report: enemies stuck as pill placeholders). Now glb-bounds-job.ts tags the 202 error deferred: true (still ends the run — in-worker second-scale retries can never outwait a 5–10 min cold cook), harness.ts settles it at debug with wire code "deferred" (one compact line, no stack), and the client jobs path applies the same classification. Real failures keep the exact ERROR path; the 5s→5min re-ask schedule, TransientJobError in-worker retries, and 401/403/400 park semantics are untouched. Pinned end-to-end: a 202 settles deferred with one debug line, and the re-asked job extracts bounds when the fetch later returns 200.
  • Owner-idempotent creates at the client-auth drain (state-delta-apply.ts), closing the chronic netcode.state_delta.row_rejected "create row id already exists" class (ledger 884 family 2, ~358/day prod-wide). Client-auth ids are deterministic (parent-qualified derived children — rig bones, blueprint children), and their delete rows are easily lost (outbox prune on socket churn, non-Streaming ingress drops, flood-budget oldest-drop), leaving a stale server copy every same-id respawn collided with forever. When a create row's id already exists AND the sender could legally have deleted+recreated it (owner resolves to the sender, or unowned remainder of the sender's live hosted place — exactly the delete gate's authority), the create now applies as an UPDATE (the drain-side mirror of the client's convertAdoptedServerCreatesToUpdates). Spawn-tier components are dropped from the converted row so a collision can never re-attribute an existing entity's chain; foreign collisions keep rejecting loudly. Conversions are counted (idempotentCreates) and logged rate-limited as netcode.state_delta.create_applied_as_update.
  • God-mode tooling out of replication: ensureHandleEntity / the outline + connector twin spawns now stamp TomeRealm:"client" + TomeSpawnedBy: tome/client-spawn, making handles first-class client-realm local spawns. Previously they carried only the anchor's copied PlaceMembership, so a hosting creator's upload predicates (isHostSimulatedEntity: explicit place, owner walk unowned) claimed them and published god-mode gizmos as canonical unowned place entities — fanned out to every client as gizmo ghosts wearing the anchor's name, and re-hovers after a lost delete rejected as <anchor>/handle/… create-collisions. With the stamps, the upload view's existing local-plane filter excludes them and isClientRealmLocalSpawn keeps them out of the hosted remainder — no predicate special-casing.
  • Patch-level fix for the prod silent-delivery-loss mechanism (ledger 884, families 1+3 — the highest-severity client-visibility bug): Bun's ws.send() returns 0 when a frame is DROPPED under backpressure, the network worker discarded that return, and the sim — whose SAB-side send had "succeeded" — marked the frame delivered, advanced lastSentTick and the dictionary watermark on the lie. When the dropped frame was a reset snapshot the client sat in AwaitingReset forever, discarding every steady-state delta while heartbeats kept the socket looking healthy: a permanent, per-client, totally silent world freeze (Balmora join-freeze, Enigma props-not-spawning), with reconnects deterministically re-dropping the same oversized reset. Two independent halves, either of which now breaks the wedge:
    • Server truth: a dropped wire.send feeds back from the network worker as a wire.send_failed ingress event; netcode flips that connection to NeedsReset (invariant, stated in code: the sim never leaves a connection marked delivered-to when the transport dropped its frame). The retry rides a 1s hold — the socket buffer that just dropped a frame is full, so no snapshot per tick is assembled into it — and the client's own ProjectionState Active request clears the hold early. The ws_backpressure container log now splits out wsSendDropped (send()===0) from queued-late (−1), and each flip logs netcode.egress.ws_delivery_failed (rate-limited per connection).
    • Client self-heal (survives the coming DO-relay transition regardless of what drops a reset): while AwaitingReset, discarded deltas and elapsed time are counted; past a threshold (5s doubling to 30s, or 64 discards after a 2s floor) the client re-requests the reset. decodePayload → null on a StateDelta stops being a silent drop too: it counts and requests a fresh baseline (unknown/non-delta payloads never do — forward compatibility). Fresh connections now start AwaitingReset instead of ingesting pre-baseline deltas blind, which arms the same self-heal for the dropped-JOIN-reset flavor. Every self-heal surfaces as a page-realm [worker-browser-host] projection self-heal warn (rides the console-forwarding lane into kiln browser logs with game context) plus projection.{selfHeals,awaitingResetDiscards,undecodablePayloads} counters in the websocket perf-diagnostics window.
  • Fixed the 5.1.9/5.1.10 cloudless-sky regression: sky-v2 integrated clouds (atmosphere.clouds on kind:"realistic"/"rayleigh" skies) rendered nothing — dial-independent, error-free, spec intact. Mechanism: the cloud coverage-field bake (sky-cloud-field.ts) renders its window bands through the fork's nonblocking compilation (vendor patches 0005/0007), which silently SKIPS draws while the bake material's node graph or GPU pipeline is cold — but the bake's planner counted skipped bands as baked and committed the window anyway (sampleInvWidth set over a zeroed back target), mapping every sky pixel onto coverage 0. No re-bake until the wind-drift accumulator crosses the re-center margin — a world with calm/slow wind never gets one, so clouds.enabled: true rendered a permanently empty sky with zero errors anywhere. The one-shot-producer contract (compilationSkipCount around the draw — the exact mechanism patch 0007 added, which #7343 wired into the environment capture, PMREM, look-pass, and post-processing) was missing from the two sky bakes. Now: a skipped band rewinds the pending bake to that band and retries next frame; the window flips only after every band's draw actually submitted. Already-baked bands are not re-rendered on retry (the back target persists), and the committed sampler mapping never moves while a re-bake is owed — the pre-fix "no clouds, never garbage" invariant holds throughout.
  • Same contract applied to the night bake (sky-night.ts): the dirty flag was consumed before the draw, so a skipped bake served a black milky way until the next authoring change. The flag is now consumed only when the draw submitted.
  • Both bake quads and materials are named (skyCloudFieldBakeQuad, skyNightBakeQuad), so a genuinely wedged compile now surfaces through the compile-skip monitor's renderer-object-draw-stalled diagnostic (the retry renders every frame while owed) instead of going silent after a bad commit.
  • Verification honesty: the planner/commit semantics are pinned CPU-side (skip-simulating renderer double in sky-cloud-field.test.ts + sky-night.test.ts); headless suites cannot certify GPU output. Device walk: boot a kind:"realistic" sky with clouds: { enabled: true, density: 0.6, opacity: 0.85, altitude: 150, speed: 2 } (the garden repro dials) and confirm clouds appear within a few frames of the sky activating — on 5.1.9/5.1.10 that sky stays a pure cloudless gradient for the whole session.

Engine v5.1.10

Released July 4, 2026

  • 2D games got a big placement upgrade: backgrounds and distant scenery can now be glued to the camera, so they always fill the screen — no more backdrops drifting away from the view, floating set pieces, or stretched skies. Repeating strips tile seamlessly across the whole view, parallax layers slide at their own speeds, and everything holds up at any screen shape from tall phones to ultrawide.
  • Mid-ground scenery (trees, ruins, set dressing between the backdrop and the play area) now sits properly on the ground across its whole parallax drift instead of floating over dips or drowning in hills.
  • onSoundEnd fires again: scripts that sequence dialogue or music off a sound actually finishing (instead of guessing with timers) work in real games, not just single-thread test harnesses.
  • Building a place and walking players into it in the same script now works — and place-travel errors show up in your logs instead of vanishing.
  • On phones, selection handles are now sized for thumbs — big enough to grab, never covering the object — and brush/draw tools have a visible ✕ to exit.
  • Objects you attach to a parent in another place now go where the parent is, deleting a parent cleans up its attached objects everywhere, and Savi gets told when something can't be saved instead of it quietly breaking on the next load.
  • Fixed a bug where one broken sprite could permanently turn off automatic sprite sizing and pixel-accurate collision shapes for every other sprite in the room.
  • Spinning objects now tumble realistically instead of holding a fixed axis — long or flat objects thrown with spin will wobble and flip the way real ones do.
  • Your game has a music system now: api.music.play("cdn/music-….mp3", { fadeMs: 800 }) starts or crossfades the room's soundtrack, stop fades it out, duck dips it under dialogue, now() tells you what's playing and where it is. Layers let you stack stems ({ layer: "drums" }). Players joining mid-song hear the right spot, and editing your UI never restarts the music.
  • api.audio.duration(clip) gives you a clip's real length in seconds (null until it's known) — no more measuring rituals for timed sequences.
  • playSound finally honors bus: ("Voice", "UI", …) so the right volume slider governs each sound, plus priority: for lines that must not be culled.
  • Your game no longer creeps down to its worst-looking quality while you're actively building — loading hitches from edits don't count against your hardware anymore.
  • Games that swap through lots of big 3D models (previews, galleries, dress-up rooms) no longer slowly fill graphics memory until rendering dies.
  • Game controls and menus now stay clear of the iPhone notch and home bar.
  • Editing big games is way snappier: script-heavy worlds apply Savi's changes in a fraction of the time they used to (a 3,000-object world went from ~2 seconds to ~0.3 per edit), and the engine no longer quietly does every edit twice.
  • Saving game data just got a lot safer: the new api.updateStorage reads your data, applies your change, and writes it back without ever overwriting someone else's save that landed in between — if two things touch the same save at once (double-click purchases, two rooms, an autosave racing a reward), both changes survive instead of one silently erasing the other. Perfect for coins, inventories, and gacha-style rewards that must land exactly once.
  • Save and load can no longer falsely report an empty save when storage is briefly unreachable — instead of your save looking blank (and risking a fresh session overwriting your real progress), the game now sees an honest "storage isn't ready yet" error it can retry.
  • Worlds built by scripts that painted tons of individual voxels no longer freeze for many seconds when the room starts — and they stay snappy while you keep editing.
  • Turning terrain off (or switching terrain types) now actually takes — no more ghost settings from the old terrain sticking around, and when a script gets rolled back, you're told everything it would have changed.
  • Music patterns written in standard strudel vocabulary now either just work (.lpq, .distort, .duck) or tell Savi exactly which vibe verb to use — no more cryptic "is not a function" crashes in your soundtrack.
  • pbr: true now works on custom geometry: sculpted and scripted meshes that paint textures with ctx.albedo() get the same derived normal/roughness/metalness detail your textured boxes and terrain already enjoy — each textured face derives maps from its own texture, no extra authoring.
  • Editing big worlds got faster again: the engine now re-processes only the objects your edit actually touched — a one-object tweak in a 3,000-object world costs what it would in a 10-object world, and an edit that changes nothing is near-free.
  • Worlds with heavy build scripts stay responsive: when lots of timers come due at once, the work spreads across ticks instead of freezing the room in one long stall — and any script that runs slow gets named in your logs (with how long it took and how to split it up) instead of freezing the game anonymously.
  • Publishing from your phone works properly now: the cover screenshots Savi needs get taken on mobile too, and a game whose flythrough picture failed once will quietly retake it next time you visit instead of staying stuck.
  • Edits that fail to save no longer disappear silently: when the server rejects a spec write (ownership race, oversized batch), the rejection now shows up in the game's logs and Savi gets told "N spec writes were rejected and did not save" — so "my changes keep disappearing after reload" becomes diagnosable in-session instead of a mystery. Writes from an object that was spawned an instant earlier no longer lose the race at all: the server briefly holds and retries them instead of dropping them.

technical notes

  • 2D camera-attach placement, wave 1 (#7449): new PositionSpec shape feetPosition: { attach: "camera", x?, y?, depth? } — the object lives in the viewer's camera frame while the entity stays vanilla underneath (origin-parked feet anchor; networking, physics-sync, and AOI see an ordinary entity — aoi.ts byte-identical). The renderer resolves placement against the presented camera (post-smoother, post-pixelSnap); size omitted = aspect-preserving frustum fill (crop, never stretch — pinned at 9:16 and 32:9); parallax is rewritten in the camera frame; repeatX renders one viewport-spanning quad with wrapped UV phase; depth clamps into live near/far with a once-per-entity warn naming both numbers. Camera-frame ontology enforced on both sides of every forbidden pair: attach-onto-physics/parent refused, and physics/npc/parenting/api.move() onto attached entities refused with teaching errors (move is a warned no-op). query() filters attached entities on all three scan paths; getProperty("feetPosition") returns the attach form. Rider: one-arg notifyDmOnce(message) — the message is its own dedup key (the natural call shape previously burned the key and sent nothing).
  • 2D wave 2 (#7813): the legacy plate machinery dies (+44/−495) — backdrop clamp groups (BackdropClampGroup, resolveBackdropClampGroups, backdropClampDeltaOf), isBackdropTextureId's clamp-eligibility role, 3-tile repeatX geometry + whole-tile re-centering for world sprites, and the pre-wave-1 orphan camera-attachments.ts. Mid-band world-sprite parallax survives as applyStandaloneParallaxOffset (taught mid pieces keep their drift). Scheduled behavior deltas for legacy content only: filename-clamped backdrop-* plates can out-travel painted coverage again; world-sprite repeatX strips render one tile wide, and bare repeatX (no parallax) now batches.
  • 2d-mode skill (#7811): mid-band footing judgment replaced after zoo falsification — feet at-or-below the deepest floor the piece drifts across (light −0.5 tuck), replacing local-sink, whose footing was only true at one camera position (camera-attach made drifting mids the default case).
  • The worker topology's decoded clip-duration relay is re-landed (ledger #802). #7088 taught the sim worker to record each audio.clipDuration post from the main-thread decoder into the local duration mirror and relay it to the server as engine.soundDuration; the #6891 client-auth merge clobbered that handler six days later, so the message dropped silently — the mirror never filled, the server's mirror stayed seconds:null, and onSoundEnd never fired in the worker topology (which is every production embed; non-worker simulation was removed from the client entry). The switch case, pre-mount buffer, and mount-time flush are restored against the current runtime-worker; the sound-end trust model (server accepts reports only for clips its own playSound referenced, healed by the cooldown-gated re-offer) is unchanged.
  • ObjectAPI script WRITE verbs (setScript / replaceInScript / insertInScript) now reject memory/ paths with a redirect error naming the working tool (str_replace_editor, which writes to savi's Supabase memory store — the PR #7146 funnel). The game spec replicates whole to every player who joins, so a memory/ key in spec.scripts strands savi's private notes on the player-facing wire; the validator now matches the boundary #7146 already drew. Deliberate asymmetries (plan p-624fbe9e): deleteScript still accepts memory/ keys (the stranded-app migration needs that cleanup path), and getScript / listScripts are unchanged (stranded apps stay readable until migrated).
  • New rate-limited server tripwire: tome.spec.memory_scripts_on_wire (warn, at most once per 60s window) fires from tome/spec-sync-server when an outbound spec revision carries memory/ script keys — meta carries key count, UTF-8 byte total of the stranded sources, and the spec revision; app/room identity rides the server logger context. Observation only: zero spec mutation, zero filtering, no replication/signature/wire change.
  • Ledger 796: container log lines now carry the real app id instead of appid:unknown. cf-edge threads the app UUID into container start env as SPAWN_APP_ID — the same boot-immutable ride as SPAWN_ROOM_ID / SPAWN_ENGINE_HASH (#421/#584) — from the iframe ?ctx payload on prewarms and from the variant-credentials Supabase lookup (one embedded select, no extra request) on bare /rooms WS-first boots. The kernel logger already reads SPAWN_APP_ID into its per-line context and DD intake tags, so every line — including the wedge-attribution lines (room.boot.wedged, worker.fatal) that fire before any late identity bind, and lines from old pinned engines — tags app + variant from the first boot line. Per-app container triage in Datadog works now.
  • Ledger 752: in client-owned multiplayer, definePlace(X) → enterPlace(player, X) in one client-simulated invocation no longer silently strands the traveler. The two calls rode different lanes with inverted server ordering (SpecMutations folded at the tick-end drain, rail.enterPlace executed at command dispatch −100); forwarded spec mutations now fold in a new server system at simulation −150 — before command dispatch — and the client flushes the tracked mutation batch onto the socket before EVERY rail.* command at the one enqueue seam all rails share (enqueueClientCommand), so program order holds end to end for emit/transferControl/voxelEdit/intent alike, present and future rails both.
  • addBehavior/removeBehavior on a just-spawned entity whose spec mirror no-opped (spawn into a place the local spec does not carry) now still record the durable op when the pending spawn already left the tracker (e.g. shipped by a mid-wrap rail flush) — previously the ECS got patched and the durable behavior ref was silently lost on reload.
  • unknown_source rail rejections (the spawn(npc); …rail on npc… one-invocation sibling race — the create rides the StateDeltas lane and can land after the Command) now also report into the behavior fault/log stream, attributed to the stamped source, instead of dying as an uncorrelated cmd.err.
  • definePlace/updatePlace/deletePlace/setDefaultPlace outside a persistence tracker now ride the same tracker-less persistence tail as patchEngine/patchRouting: client-auth behaviors forward them source-stamped call-time (previously a bare call never reached the server at all), and the server/singleplayer authority enqueues them for its own drain.
  • Rail command handler throws (e.g. enterPlace() failed: place … does not exist) now report into the behavior fault/log stream — runtime log (visible to getLogs) plus the DM notifier, attributed to the stamped source entity — before the dispatcher's cmd.err reply, which previously died uncorrelated on the client.
  • Ledger 810: god-mode handle gizmos now size from a 56px thumb target with a 44px iOS floor and an object-fraction cap, converting px→world through the existing UiGlobalsResource viewport mirror; the COARSE_HANDLE_SCALE constant is deleted.
  • Ledger 811: armed touch tools get a visible ✕ exit chip that routes the same god:cancel-tool desktop Escape sends.
  • Ledger 776: spawn() with a parent now resolves the child's place from the PARENT's live place instead of the spawner's — a main controller spawning { parent: "skurp" } while skurp lives in sparkmines no longer mints a cross-place parent/child split at birth (entity and persisted row both land in the parent's place). An explicit place that conflicts with the parent's is still honored but warns on the fault channel — it authors a split.
  • Ledger 777: the destroy cascade's row sweep is now doc-global across all three seams (kernel recorder collectSpecSubtreeRowIdsForDestroy, kernel spec-mutation fold, kiln doc-apply) — a child row split into another place leaves the doc WITH its destroyed parent instead of upgrading to a permanent orphan. Parent refs bind by same-place-wins (a ref resolves to a row of that id in the child's own place first), so #184 cross-place duplicate ids never cascade into another place's same-named family. Seeding by the target's ROW id also lets place-namespaced runtime ids (placeId:objectId) collect their bare-id children.
  • Ledger 779: the persist gate (isMutationPersistable) now refuses spawn rows whose parent can never have a row of its own (player/, camera, tome/exec, system/, _ prefixes) — previously the row persisted and reloaded orphaned forever (the one-nameplate-per-player debris class). The live spawn is untouched; the refusal is taught via the normal fault channel instead of silently minting.
  • No data repair: pre-existing split/orphan rows in stored docs are untouched (separate creator-consent call).
  • Ledger 816: a DrawSprite written without a texture (neither the ObjectAPI sprite setter nor spec apply validates the field — e.g. obj.sprite = { opacity: 0.5 } on an entity with no existing sprite ships texture: undefined) made the server-only sprite/metadata-hydration system throw a synchronous TypeError on every pass; under sustained sprite-write pressure the scheduler's 5-retry ladder completed and permanently disabled hydration for the rest of the process — every later sprite in the room lost auto-sizing and hull colliders until the container recycled. Hydration now skips texture-less sprites (nothing to hydrate from) instead of throwing.
  • FatalSystemError's message claimed "server restart required" — false: the runtime worker catches it and the system is simply disabled for the process lifetime. Message and docs now say what actually happens.
  • Mantle: Box3D technique harvest (PR #7741). Adopted: gyroscopic torque via implicit NR-1 solve in the deterministic lane (pure arithmetic + quat rotate helpers, no transcendentals; scratch rebuilt per tick — nothing new outside the snapshot except contact-event prev-overlap sets, which ride the arena per the graduation clause). Rejected with measurements (docs/physics-native/box3d-harvest-notes.md): static-softness ζ=5 and central friction — both breached the heightfield-rest neighbor-sensitivity epsilon; cross-tick warm starting rejected on the snapshot veto. Cross-runtime golden hash chains identical V8↔JSC; 390/390 mantle tests both runtimes.
  • Music API (plan p-9989dd7d): engine-owned music orchestration. New TomeMusicState component on the tome/spec entity (replicate:"always", reset-proof, carried in the reset snapshot — late-joiners receive music state in the same packet as the spec) holding per-layer {clip, volume, loop, startTick, fade, epoch} targets plus a global duck envelope. New ObjectAPI namespace api.music.{play, crossfade, stop, duck, now} (server-realm; bare music.* in run_script) writes it; a new music-client system applies it through the existing AudioTrack/voice pipeline (Music bus — user volume/mute govern structurally). Crossfades, same-clip in-place volume ramps, named layers (parallel stems), tick-anchored positions, and late-join seek are engine-owned; playback state never touches GameSpec asset refs (regression-pinned). Legacy musicShift runtime untouched; per-player musicShift state suppresses the global default layer on that client while active.
  • Late-join seek plumbing lit up: AudioEmitterValue.time → prep now produces startOffset; the renderer's buffered path start-offset bug fixed (source.start(when, offset) — was a delay, not a seek) with loop-length wrap; music layers force the buffered decode lane past the stream heuristic.
  • api.audio.duration(ref) (server) + audio.duration(ref) (client hatch): sync clip length in seconds, null until the fact mirrors (first decode/report; server reads mark the clip referenced so the fact converges). Rides the #7809 clip-duration relay.
  • playSound now honors bus: (named mixer bus, unknown coerces to "SFX") and priority: (0–100 clamp) — previously silently ignored; defaults unchanged when absent.
  • Zoo Audio zone rebuilt on music.* (trigger-volume room music, jukebox pad, genre-fusion stems pad, duck on the voice pad); the hand-rolled ~95-line ui.js director is deleted; a minimal hatch mount-lifecycle fixture remains.
  • audio skill net-shrinks 110→75 lines (7,849→6,778 chars): the taught director pattern is absorbed by the API; hatch teaching survives as the escape hatch.
  • tome.reconcile.parent_missing warns once per row per boot instead of on every applySpec pass (ledger 776 warn-side, #7754 — was ~5.7k DD events/15d plus ~3/min creator-visible runtime-log spam that nudged Savi into re-fixing the same row). A changed parent value earns a fresh warn. The cross-place case gets honest text: when the parent exists as a row in another place, the warn says exactly that and suggests removing the row or moving it to the parent's place, instead of claiming the parent is missing.
  • Quality governor evidence hygiene (ledger 782, #7760): frames inside the compile/load grace window no longer count as shed evidence — the judged window flushes on every grace sample and steady samples push only after the grace check, so post-grace judgment starts clean. Was ~1 phantom rung shed per Savi edit / place-warp: healthy hardware ground to the quality floor over a 5–10 minute build session. A genuinely heavy world still sheds within the sustain (pinned). Dumps gain a session-cumulative governor transition ring (~last 32 shifts, timestamped with triggers) + landing state.
  • Renderer model retain/release + keep-alive eviction (#7617): the model cache's modelEvictionFrames: MAX_SAFE_INTEGER pin dies — visuals lease their LOD-resolved model ids (two lease slots, settled through every LOD transition) and release on detach; models ride the texture two-tier eviction (grace window measured from release, per-device keep-alive byte budgets 192/64/48MB, oldest-released first past budget). disposeModel now disposes GLB-embedded textures — material.dispose() never freed them and they were the dominant leaked bytes. Fixes the Final Abyss WebGPU device-loss class (one preview entity cycling 28 bespoke 20–27MB GLBs → 1113MB texMem → createBuffer failing at 192 bytes → device death). Riders: device-lost latches the worker-silence watchdog (kills the contradictory follow-up report), and renderer-device-lost carries the GPU adapter description.
  • Ledger 809: kiln hosts measure env(safe-area-inset-*) via a probe element and forward the values once per host across the iframe boundary; the kernel publishes --spawn-safe-area-* CSS vars on <html> with an env() fallback (256px clamp, non-finite→0). God-mode overlays, touch controls, and the server-behind banner consume them.
  • Ledger 787: room.server_behind.episode no longer mixes units. The old headline avgTickWorkMs was per PUMP FRAME (tickWorkMsTotal / framesLastSecond) while phases[]/topSystems[] are per tick — and exactly when the log fires, frames coalesce up to maxStepsPerFrame (8) ticks, so the headline read ~7× the attribution (a P3 burned 25 min on a phantom "87% unattributed"). The field is renamed avgFrameWorkMs (maxTickWorkMs → maxFrameWorkMs), and the log now also carries per-tick avgTickMs, the coalescing factor stepsPerFrame, unattributedTickMs (avgTickMs − Σ phases: jobQueue.poll/world.prune/ctx overhead outside phase brackets), and per-phase residualAvgTickMs (phase avg − Σ that phase's systems: commitTick/stager/flush). All computed at log time in buildServerBehindEpisodeLog (server-behind-episode-log.ts) — zero hot-path additions. No in-repo DD config queries the old field names; saved DD queries on avgTickWorkMs/maxTickWorkMs need the new names.
  • Spec→ECS apply cost collapse (plan p-a2af390c, #7853 + #7826): behavior compilation and signatures now cost O(unique script contents), not O(objects × applies) — a content-keyed compile cache with per-dep source-hash revalidation (failures cache too: a broken lib costs one failed compile per unique content instead of one per object per apply), and behavior signatures are murmur3_128 hashes over exactly the old inputs instead of embedded full sources (185.5MB retained signature chars → 2.2MB on a 3,352-object prod spec — the 3GB-container GC spiral). Grown-spec apply: 1,863ms → 298ms; broken-libs apply: 11,137ms → 506ms. onSpawn re-run semantics pinned identical by op-count tests.
  • Server spec pushes apply once (#7826): every push, boot, and reset applied the whole spec twice — the direct apply plus tome/spec-sync-server's echo re-apply on the next tick (DD-attributed at 2,917ms avg / 20,420ms max per echo on 3,352-object prod worlds). The three lanes that tracked raw spec references in TomeSpec now track the applied snapshot (reference identity is the codebase's echo-suppression contract); a FAILED apply deliberately keeps the raw reference so the sync pass retries.
  • New ObjectAPI verb api.updateStorage(key, updater, callback?, { attempts? }) — the engine-owned safe read-modify-write for storage. get → updater(current) → versioned (compare-and-set) write, automatically retried against the fresher value when another writer lands first (the conflict echoes the current head, so a rebase costs no extra read; default 4 attempts). The updater must be pure — it may run multiple times; return undefined to abort without writing. Callback results: { ok: true, value, version, attempts }, { ok: true, aborted: true }, { ok: false, error } with codes storage_conflict_exhausted (carries attempts + currentVersion) and storage_cas_unsupported. Works in behaviors (callback), lifecycle/cron (awaitJob too), and player-context — the loop's legs ride the existing forwarded storage jobs, so the user/<self>/… self-scoping applies unchanged. Custom spec jobs get the same primitive as env.update(key, updater, { attempts? }) and env.getWithVersion(key).
  • Storage vocabulary is now version-aware: storage:get results additively carry version; storage:set accepts baseVersion (absent = last-writer-wins as before; 0 = create-only expect-absent; N = compare-and-set) and returns { ok: true, version }. A stale base fails with error.code: "storage_conflict", retriable: true, and error.details: { currentVersion, currentValue } — the existing if (!result.ok) return script idiom degrades to a safe SKIP, never a clobber. Identical on both storage lanes (job-pool rail and lifecycle/cron direct-SDK rail) and across the forwarded multiplayer wire (details now rides job error payloads).
  • storage:set's ttlSeconds is now actually honored on the job-pool rail (it was typed and accepted but dropped before reaching kiln); an expired document reads as absent.
  • Honesty fixes: a failed in-memory-rail storage read now propagates its error instead of being swallowed into undefined (a failed get must be distinguishable from empty — the ledger 793 pattern, one rail over), and a compare-and-set write that the storage backend cannot honor (deploy-overlap window with a pre-CAS kiln) fails typed as storage_cas_unsupported instead of silently degrading to last-writer-wins.
  • SDK surface (@spawnco/server, ships in this build): documents.getWithVersion(name), documents.set(name, value, { baseVersion?, ttlSeconds? }) → { version } throwing typed DocumentConflictError { currentVersion, currentValue } on 409, and documents.update(name, updater, { attempts? }) — the same CAS loop, awaitable. Requires the kiln documents-funnel deploy (version-aware GET/PUT); against an older kiln the new verbs fail typed rather than lie.
  • Ledger 793: room-scoped job-pool storage now fails honestly instead of lying. In the variant-change window (pool torn down and recreated before SDK identity resolves) — and any time a room pool has no working SDK — storage:get/set/del/list/query/lock/unlock jobs previously fell back to per-thread in-memory storage and reported ok (reads as value: null, writes persisted nowhere), the exact failure shape that defeats creator-written save guards (_saveLoadOk-style flags armed on a blank read, then defaults overwrote the real save). All seven storage surfaces now fail with error.code: "storage_unavailable" and a self-teaching message; the code rides both lanes verbatim (server-local and forwarded multiplayer). Dev/harness/test pools that intentionally run without a storage backend keep the quiet in-memory fallback unchanged (tri-state sdkIdentity discriminator: object = resolved, null = room-scoped unresolved → honest failure, absent = identity-less pool → in-memory by design).
  • Known limitation, declared: a worker spawned in the null-identity window keeps failing loudly until natural churn — proactive identity-refresh respawn is a named follow-up. Field specimen: a_dreamcatcher's Spawn Tactics progression wipes (dump 77cae01a).
  • Ledger 790: the terrain-edit journal is now compacted at the durable-store seams. Accumulated single-cell voxel-set commands fold into dense per-chunk voxel-stamp commands (the existing palette+RLE encoding) on document save and on document load, so the durable payload and every boot rehydrate are O(edited cells, chunk-bounded) instead of O(lifetime edit count). Semantics-preserving: identical replayed voxel state, appliedRevision/lastServerTimestamp untouched, nonzero-state cells keep their single-cell commands, and commands at or before the last region-shaped command keep their exact positions. Old uncompacted documents still load and self-heal (one compacted re-save on first boot).
  • Terrain-edit fold now re-serializes only chunks whose journal changed (per-chunk appliedRevision/lastServerTimestamp reuse against the previous store entry) instead of re-cloning the whole place per edit, and journal serialization dropped its per-command deep clones (commands are immutable engine-wide). The near-cap size check estimates payload bytes instead of JSON.stringify-ing multi-MB documents on the sim thread.
  • Receipt on the prod-shaped 39k-command specimen (364 chunks): boot rehydrate 292ms → 38ms, single-edit fold+save 125ms → 1.1ms, saved document 39,289 commands / 5.2MB → 365 commands / 0.4MB (dev hardware; the prod container amplified the old cost to ~20s freezes).
  • patchTerrain that changes kind now replaces the terrain wholesale instead of deep-merging residue from the old kind (r-56, #7762; class precedent #7734's patchAtmosphere fix) — applied with the shared switchesDiscriminatedKind predicate at all three folds (live ObjectAPI, kernel spec-mutation fold, kiln durable fold), so patchTerrain({ kind: "off" }, "main") on a heightmap yields { kind: "off" } exactly. Same-kind and kindless patches keep deep-merging. (patchCamera has the same latent class — enumerated, deliberately not fixed here.)
  • Transactional run_script rollback now reports everything it discarded — the silent-swallow that actually ate the specimen's kind-switch (an unrelated patchPlayer schema error rolled back the script and the terrain verb vanished without a trace).
  • Ledger 787: terrain-reanchor's per-anchor re-resolve no longer routes through the full creator-facing createObjectAPI().setProperty("feetPosition") dispatch. updateTerrainAnchoredEntity calls the feetPosition writer directly (terrain-height sample + anchor sync + transform write + owned-scatter-field translate) — the dispatch wrapper's pieces (ObjectAPI construction, sprite-warn doctrine, creator-write schema validation, tracker-gated mutation recording) are state-inert on engine re-apply paths. Parity with the old path (component state + pending dirty marks on twin worlds) is pinned by terrain-reanchor-parity.test.ts.
  • The reanchor system additionally samples the composed height BEFORE writing: when a chunk-version bump didn't actually move the ground under the anchor (sculpt touched the chunk but not the anchor's cell), the write is skipped entirely — component writes were value-deduped no-ops anyway, and the one non-deduped piece, the static physics body dispose+rebuild, is exactly what made per-tick field churn cost ~34ms/tick at 119 anchors on prod app 28642021 (server_behind episodes). The version gate itself was already per-chunk (composedFieldVersionAt sums the authored+runtime versions of the one chunk under the anchor), so no new versioning surface was needed.
  • Tome warn-class log lines no longer land in the Datadog error lane. Self-correcting [Tome] warnings (mutation-warn rail — e.g. spawn() properties.parent auto-hoist — plus interpreter spec-normalization/terrain-mark-skip warns and the camera-state function filter) were written via bare console.warn, which is stderr on the server, and the container log tee ships every stderr line at status:error. They now route through tomeLogger.warn (winston status:warn on the server; unchanged console.warn on the client and in worldless tests). Message text is unchanged, so text-grep dashboards keep matching. Genuine failure logs (tomeLogger.error, purchase-refusal warns) are untouched.
  • Vibe strudel-divergence (ledger 805, #7859): chain verbs whose DSP already exists are now honest aliases (.lpq(r) → .resonance(r), .distort/.dist → .drive, .duck(on) → the duck mod), and ~60 real strudel verbs vibe deliberately lacks (chop, vowel, off, sometimes, n, bank, …) throw teaching errors naming the nearest real verb on the existing fault rail — instead of a bare "…lpq is not a function" TypeError (87×/night in prod). A load-time guard refuses any table entry that would shadow a real chain verb; deliberately-divergent verbs (.rev = reverb send, .coarse = semitone transpose) keep vibe semantics, pinned by test.
  • Custom geometry supports pbr: true for materials (Jacob, #inventors 07-03). The gap: normalizeMaterialSpec derived Patina packed-variant map URLs from material.texture alone, so custom geometry's primary texturing flow — ctx.albedo() textureRuns — silently dropped the request (whole-material {texture, pbr: true} already worked; the standalone bespoke lease binds all maps). The fix consumes the existing pipeline rather than mirroring it: the request now rides DrawMaterialOverrides as a canonical pbr: true flag, and the bespoke run-value assembly (materialForTexture) derives each RUN texture's own maps via the same buildFal1PbrMapUrl — normal/roughness/metalness/height, same URLs and transforms primitives ride. Non-derivable run sources (procedural scripts/, pre-compressed .ktx2) keep the material's own maps. Derivation also keys off the material's effective texture (texture alias or flat map — the reader round-trip shape god-mode writes), and the spec-asset preloader mirrors that. "pbr" joined SOLID_BATCH_OVERRIDE_KEYS so the carried flag never knocks a material off the batch lanes; pbr absent/false stays byte-identical to before (pinned in bespoke-pbr-runs.test.ts, material-utils.test.ts, tome-primitives.test.ts).
  • playSound now warns (once per entity+kind, mutation-warn rail → getLogs + ops trail) when a GIVEN bus: or priority: value is unusable — unknown/wrong-case bus names (coercion to "SFX" unchanged) and non-numeric priorities (default unchanged). Existing games passing bus: "voice" (wrong case) or priority: "high" (string) keep exactly their old runtime behavior but start seeing the teaching warn in getLogs. Absent options remain silent; the dedupe key is value-free so per-frame varying bad values cannot fill the log ring or churn other warn classes' cooldown state.
  • Spec apply O(delta) close-out (plan p-a2af390c PR-C, #7919 — finishes 5.1.10's apply-cost collapse): (1) delta-0 short-circuit at the applySpec seam — a spec structurally equal to the applied generation skips the whole derivation skeleton, with retry vetoes so a short-circuit can never skip a retry (completed-apply binding via TomeAppliedGenerationResource.specRef === GameSpecResource, degraded-lowering retries, compile-error re-report contract, scatter missing-children recovery, restore window, residency growth, instance-registry drift, bare-id owner-map changes in place-filtered worlds; reset drops the record explicitly). (2) Changed-set-first apply loop — per-object visits happen only for objects whose reconcile inputs changed (expanded-def structural inequality covering realm/parent beyond the four signatures, place moves, spec-authored hierarchy-pair changes, runtime-flagged scatter recovery), with the pinned full visit on replace; unchanged defs reuse the old signature object, killing the O(spec) re-stringify. (3) Structural sharing over the whole-spec resource clone — regions structurally equal to the applied doc reuse its engine-owned subtrees, deciding equality at each child's seam (an equal subtree is traversed once, allocates nothing); spec-row arrays get an id-keyed assist so inserts/deletes/reorders reuse every surviving row; the two in-apply writers of the stored doc (defaultSpawnPoint backfill, survived-destroy retry rows) are copy-on-write. (4) New tome.apply episode on the 787 instrumentation rail: structured per-apply completion log with duration, total/visited/skipped/spawned/destroyed, expand passes, degraded count, replace + shortCircuit flags, and caller lane. Hardening riders from the #7894 review: structurallyEqualSpecDocs distinguishes array holes from stored undefined (the dangerous direction — a revision suppressed where master bumped); cyclic doc shapes are contained (a throw means "cannot prove equal" / plain deep clone, never a failed apply); the spline-orphan sweep runs on both paths so prediction-rollback strays don't survive the short-circuit.
  • Shared __proto__ guard on spec-doc clone walks (#7923, flagged by the adversarial review rider on #7919): creator-authored spec docs can carry own __proto__/constructor/prototype keys (JSON.parse produces them), and naive target[key] = value clone walks turned an own __proto__ data key into the clone's [[Prototype]] — silent data loss, phantom inherited state, and a prototype-pollution foothold. One shared guard (setSpecDocKey: Object.defineProperty re-key matching structuredClone's own-key semantics, so guarded clones stay structurally equal to their source — spec hashes and the #7919 delta-0 short-circuit ride that equality) now applies to every doc-shaped clone/copy walk in src/tome: deepCloneUnknown, cloneValueReusingApplied, mergeRuntimeStateWithSpecDefaults, cloneValueForOverlay, mergeField/mergePlaceFields/mergePlaces, applySpecStateDefaults, toWireSafe, camera cloneRecord/mergeDeep, and material passthrough/params. Regression test per guarded walk plus an Object.prototype pollution canary; applySpecStateDefaults gates on Object.hasOwn, not in.
  • tome/timer-dispatch gains a per-tick wall-clock budget (25% of the tick interval, clamped 4–100ms — ~8.3ms at 30Hz; env knobs SPAWN_TIMER_DISPATCH_BUDGET_MS / SPAWN_TIMER_CALLBACK_WATCHDOG_MS), checked BETWEEN callbacks: due timers past the slice spill to the next tick instead of piling into one mega-tick (the DEADZONE 9.4s-tick class, ledger 829). Nothing is dropped or reordered by the budget — a spilled timer keeps its original due tick and runInTicks floors at 1, so arm order holds across the spill boundary by construction. A per-callback watchdog (25ms) writes one creator-legible line per slow callback naming script, owner, and elapsed ms — runtime log (getLogs) teaching runInTicks slicing, plus tome.behavior.timer_slow on the DD rail — throttled per script+owner (30s cooldown, warn-key map hard-capped at 256 with warn-recency eviction). Spill breadcrumb + tome.timer_dispatch.spill carry spilled/ran/budgetMs/elapsedMs and armedTotal, the backlog gauge that makes the arm-rate-above-drain-rate regime visible before it becomes a memory problem. Honest scope: this is attribution + pile-up bounding, not a per-callback preemption — a single synchronous monster callback still wedges its own tick for its full duration (the cure is creator-side slicing, which the watchdog line teaches); a throw is not budget-exempt (wall time spent is spent).
  • An explicit physics.collider shape override ("box" | "sphere" | "capsule") on a pierced primitive — a wall with holes, archway, squareArch, hollowCylinder, ring, torus — now warns (once per entity+warn class, runtime-log rail → getLogs) that it replaces the hole-respecting derived collider with a sealed shape via deriveColliderFromBounds, naming the object, the override, and the remedy (remove the collider field). Collider behavior is unchanged — explicit overrides still win (sometimes a deliberate perf simplification); the seal just stops being silent (dump 2eba2de7: a doorway went solid with zero feedback). Dedupe keys are value-free per the #7943 review law (entity+warn class, never the offending value), and the existing collider-shape warns moved onto the same keying so one warn class firing no longer masks the other for the session.
  • Mobile screenshot famine, capture-source half (ledger 833; kiln gate half = #7945): the seo-preview capturer's undocumented isMobileDevice() early-return is removed — the byte-identical screenshot pipeline (renderer-worker RPC → resize → uploadClip) was already proven on phones by the savi-note capturer, and the old predicate's maxTouchPoints > 1 check also starved touch-screen laptops. And planFlythroughCapture now treats a world flythrough row with a NULL thumbnail as heal-on-next-visit instead of "already captured forever" — the 06-25 class where one failed thumbnail extraction permanently poisoned a game's press covers; the (variant, clipId, kind) upsert repairs the row in place, and shouldUploadFlythrough keeps failed heals from uploading.
  • Spec-mutation forward rejections are no longer Datadog-only (ledger 831/g-77). Every drop site in spec-mutation-drain.ts (not_owner, privilege_required, target_not_writable, batch_too_large, payload_too_large, boot_grace_overflow) now records a spec write dropped: <verdict> runtime-log entry — visible to Savi via getLogs/run_script — content-key-throttled (30s, shared with reportRepeatingBehaviorError) so a ×26 burst is one entry. Creator-relevant classes (not_owner from a creator client, boot-grace/size drops) additionally DM Savi through TomeDmNotifierResource under the shared engine-error cooldown + window budget; visitor privilege_required stays log-only (anti-forgery gate working as designed) and suspended_session stays fully silent (takeover race, by design).
  • The just-spawned drain race is closed with a bounded hold-and-retry: a batch whose source authorization fails for a not-yet reason — source entity unknown (its create row can still be queued for next preUpdate when a socket flush straddles the drain phases) or unowned source in a place with no live host yet (host rows land at simulation −90, after the −150 ingest) — is held and re-judged for SPEC_MUTATION_SOURCE_GRACE_TICKS (3) ticks before dropping loudly. Definite forgeries (source exists, owned by someone else) still drop immediately; nothing folds or persists on a hold, so the anti-forgery gate is unweakened. The hold is double-bounded (grace ticks + MAX_HELD_FORWARDED_SPEC_BATCHES), and the shutdown flush (drainTomeSpecMutationQueue(world, { final: true })) resolves holds deterministically: one last judge, then loud drop. A retried batch can persist after a later-arriving accept from the same sender for a different entity — benign under per-entity single-writer.

Engine v5.1.9

Released July 3, 2026

  • ⚠️ If your world suddenly looks different after this update: your custom look script is running again. It was silently crashing on the last few engine versions (since around 5.1.5), so your world was rendering with no look applied at all. The crash is fixed and your authored look applies again — that new appearance is your own look script working. If it feels off (too dark, too bright, too saturated), ask Savi to retune the exposure — don't delete the look.
  • If a model ref in your world is malformed (a name the asset server can't parse), the logs now tell you once, clearly, which ref is broken and why — instead of retrying it silently in the background forever.
  • Behind-the-scenes: game-server logs now identify which world and engine version they came from, so problems in live games get traced and fixed faster. Nothing changes in your worlds.
  • Savi now judges a custom material's cost by the measured GPU number in every preview, calibrated against real games' materials — expensive shaders (especially layered fur and transparency) get priced honestly before they ship instead of being mistaken for free.
  • Wrong-shaped primitive keys no longer fail silently: writing topRadius where the engine wants radiusTop now warns with the correct spelling instead of quietly rendering your shape with default dimensions — and Savi's other dead-end errors (misplaced primitive dims, event handlers in one-shot scripts, mis-called primitive helpers) now say what to do instead of just what went wrong, so she fixes them in one step.
  • Behind-the-scenes: the engine now reports exactly where script runs spend their time, so slow run_script moments get found and fixed faster. Nothing changes in your worlds.
  • Worlds with lots of effects now render far more of them on the GPU: idle effects lend their GPU budget to the ones actually firing (and take it back when they fire again), instead of the first few effects permanently hogging it while everything else dropped to the low-quality fallback renderer.
  • Fixed rain and similar effects falling through flat terrain on the GPU path — drops now land and die exactly at the ground (splashes, rings, and hearts appear on the surface instead of underground).
  • Behind-the-scenes: effect re-fires stay correct and warm around edited effect art and slow texture loads. Nothing changes in your worlds.
  • Fixed a ~1s whole-game hitch (sometimes "all meshes reloading") when a trigger effect fired again after sitting idle for minutes — level-ups, kill ceremonies, and other occasional effects now keep their GPU resources warm and re-fire instantly.
  • Effects can now burst into multiple children on death: when(heart.dies, { count: 8 }) shatters every dying heart into 8 shards (up to 32 per death). Combine with probability to make only some deaths shatter — a shattering death always bursts the full count. The shards' spread, speed, and look stay yours: author them in init:/inherit: like any population.
  • Objects no longer silently vanish after joining: if a generated mesh gets lost on the way to your screen, the engine now notices within seconds and re-sends it automatically — and it leaves an honest log entry instead of nothing, so "the spec says it exists but I can't see it" stops happening (and stops looking like Savi lying about your world).
  • Savi now builds isometric and 2.5D sprite buildings from modular floor, wall, and roof pieces on the grid — with doors and windows as wall variants and roofs that hide when you walk inside — instead of painting each building as one flat image.
  • Fixed images and models with transparent edges (cutouts, sprites, foliage) showing solid black around them — transparency renders correctly again.
  • Material previews now show the real GPU cost of your material — the number that actually decides whether a shader is cheap — instead of only the CPU cost, which stayed misleadingly flat even for very expensive materials. Savi reads it with every preview, labeled as one point on the cost curve (your device, preview resolution), so expensive materials get caught before they ship.
  • Worlds with extremely dense decoration carpets no longer risk freezing on the first frame of weaker devices — the engine caps how many instances it builds at once (generously), tells Savi in the logs when it did, and the quality system keeps tuning from there.
  • Scattered decorations (grass, flowers, rocks) now stay out of buildings stamped as terrain structures — no more flowers poking through walls or grass carpeting a roof. Want plants inside a ruin on purpose? Set filter: { avoidStructures: false } on that scatter.
  • Savi's script edits now reach you while you're in the game: previously in singleplayer, once you'd built anything in-session, every fix Savi saved was silently ignored by your running game — it looked broken until you refreshed the page, while friends joining fresh got the working version. Edits and version restores now apply live, and if an edit ever is withheld, the game says so in the logs instead of staying silent.
  • Heavy building no longer stutters the world for players — when Savi and her wisps save in rapid bursts, the room now applies the newest state once instead of hitching on every save.
  • Fixed: one malformed field in your game's spec can no longer break ALL building — before, a single badly-shaped list (for example after asking for a full game wipe) silently blocked every change from reaching the game for minutes. Now the engine repairs what it can, keeps applying everything else, and tells Savi exactly which field was wrong so she can fix it in seconds.
  • Savi can now truthfully wait for the world to finish loading: api.getWorldResidency() reports when everything has actually streamed in on a player's machine (and how much is still pending), so start buttons, intros, and loading screens can gate on the real thing instead of timers or framerate guesses.
  • Behind-the-scenes: game tabs left open for hours no longer keep hammering the server with expired credentials — nothing changes in your worlds.
  • Behind-the-scenes: connection-health reporting got more accurate — nothing changes in your worlds.
  • One long stall no longer snowballs into a permanent slideshow: when your tab hiccups for over a second (backgrounding it, a big world update landing), the engine catches the renderer up with one full refresh — it used to mistake its own refresh for another stall and refresh forever (climbing memory, a hot GPU, an eventual crash on a world you were just standing in). Now one stall means one refresh, then the game goes back to normal streaming.
  • Repeated "nothing changed" updates stopped chewing the frame budget: per-tick scripts that re-apply the same particle or light settings every tick, and re-delivered identical terrain configs, now recognize "same content" and stay quiet — real changes flow exactly as before.
  • Fires that never burned out now burn out: seconds() with no argument now reads the current game clock in seconds instead of silently returning 0, so scripts measuring elapsed time with it (campfire fuel, cooldowns) work the way they always meant to.
  • Big terrain worlds stopped paying a server tax for standing still: the server no longer re-checks every terrain chunk's collider every tick, only the ones that actually changed — the biggest cause of "server running behind" slowdowns in large worlds is gone, and heavy terrain editing stays smooth because rebuild bursts only cost the chunks being rebuilt.
  • Game menus no longer trigger the browser's right-click/copy-paste menus, text-drag selection, or image drag-to-save — automatic now, no script guards needed. Want copyable text somewhere? Add select-text to that element; a deliberately draggable image just needs draggable="true".
  • Real WebAudio for your games: write playlists, crossfades, ducking, and custom mixes as plain WebAudio in your UI script's onMount — the engine hands you its live audio graph (audio.context, audio.bus("Music"), audio.buffer(ref)), your sounds respect every player's volume settings, and the engine cleans your graph up on every edit automatically.
  • Music can finally end: track-end transitions, playlists, and menu→gameplay music are now ~15 lines of vanilla WebAudio instead of workarounds around musicShift.

technical notes

  • Carried warning from 5.1.8's three r185 re-vendor (#7343): games upgrading from ≤5.1.6 get their custom look scripts running again — the #661 getNodeType TSL crash silently killed look builds on the r184 engines (5.1.5/5.1.6), so those worlds rendered with no look applied. Authored grade/vignette/grain chains apply again on upgrade and the world's appearance can visibly change; the creator-facing warning rides this version's changelog and migration notes.
  • Bounds prefetch now classifies HTTP 400 from the Magic CDN as a malformed asset ref and parks it on the slow probe cadence immediately (like 401/403), with one loud log line naming the bad ref and the CDN's teaching error — instead of riding the 5-minute retry cap forever while the room lives (ledger 724: extensionless model refs burned 800–3,500 log events/day since Jun 19). 404 deliberately stays on the exponential lane: a missing asset can heal (generation completing, an asset created later), and the 202-defer path already covers pending generation; a 400 never heals without a spec edit.
  • Container stdout/stderr telemetry now carries identity tags (ledger #734). The game-server image's Datadog log tee (scripts/container-log-tee.mjs, wrapped around the entrypoint by scripts/container-entry.sh when DATADOG_API_KEY is present) now tags every shipped line with engineVersion / engineHash / variantId / roomId read from container start env (SPAWN_ENGINE_SEMVER / SPAWN_ENGINE_HASH / SPAWN_VARIANT_ID / SPAWN_ROOM_ID — placed there by cf-edge's startOptions.envVars, boot-immutable because they key the GAME_CONTAINER DO), mirroring the winston Http transport's ddtags order. Previously raw stdout lines (console.* bypassing winston, [job-worker] prints, Bun panics) shipped with appId:unknown and no identity beyond stage — ~95% of source:cf-container volume — so prod investigations joined attribution by hand (boot-line hash greps, body wildcards). appId keeps its unknown boot fallback on tee tags (it binds post-boot via the spec fetch); appId joins ride the parsed attributes of winston JSON lines. No key = direct exec, byte-identical to before.
  • Provenance fix: the tee (Dockerfile ENTRYPOINT switch + container-entry.sh + container-log-tee.mjs + tests) has been running in the deployed staging/prod images since ledger #442 but its source only lived on an unmerged image-build commit (cfe6b5622) — a rebuild of the image from master would have silently dropped it. The source now lives on master. Rides the container IMAGE, not the kernel bundle — takes effect at the next container-build.yml run + cf-edge deploy.
  • custom-materials skill: the cost curriculum now anchors on the real GPU number preview_object reports (the gauge added by #7636) instead of prose cost rules. Savi is taught to read the GPU line after every material create/edit, treat it as one point on the cost curve (phones pay steeper, worst for transparency and layered cutout), and calibrate against four measured real-game anchors — plain node math ≈1×, one fractal-noise tap ≈3×, a giant unrolled ALU shader only ≈16× but with a ~650ms first-use compile hitch, shell-layer cutout fur 60–111× (overdraw multiplies the whole shader by layer count). The two off-gauge axes (compile hitch, per-frame needsUpdate texture re-uploads) are named. Cost rules the gauge answers better were cut: the CPU-encode check loop, the MeshPhysicalNodeMaterial-is-expensive class tag (measured: clearcoat ≈ noise floor), and the glass hero-object rule. Anchors are one-rig measurements (M4 Max, real prod materials built through the production buildScriptedMaterial runtime), taught as exchange rates, not fleet truth.
  • Error-channel quality batch (ledger 723, from the Starfall triage): four teaching fixes on the paths where Savi/wisp mistakes either failed silently or named the ban without the road. (1) Unknown keys inside properties.primitive — previously accepted without a sound while the mesh regenerated with default dimensions — now warn (fail-soft, getLogs-visible) with a did-you-mean against the kind's schema keys (topRadius → radiusTop); key sets derive from PrimitiveSpecSchema, no hand list, and the lane never rejects because live specs already carry junk keys. (2) The spawn placement rejection for primitive dims left flat in properties (width, radiusTop, …) now names the destination: they belong inside properties.primitive. (3) The transactional run_script guards (api.on, api.runInTicks, api.runInSeconds, api.runSchedule, api.job, api.createJoint — the biggest guard class fleet-wide, 468 events/112 apps/7d) each teach the alternative in one line: handlers/timers/jobs live in behavior scripts. (4) builtin/primitives helpers — the only positional API on an otherwise object-shaped surface — validate their first argument and throw a teaching error naming the positional form when a props object lands in the api slot, retiring the unreadable minified Q.uniqueId is not a function class.
  • Per-exec structured log tome.exec.settled (ledger 714 r2 — the exec breakdown black hole): the host already measured snapshotMs / queueWaitMs / workerExecMs / rebuildMs / mergeMs / snapshotBytes per exec but discarded everything except durationMs into an unread resource — exec-lane stalls were unattributable in Datadog. Every settle now emits one info line with the full breakdown (plus ok/terminated, scriptHash, totalMs, simThreadMs), volume-gated: failures/terminations, sim-thread stalls (snapshot+merge > 8ms), and slow execs (total > 250ms) always log; healthy fast execs sample 1-in-20 per room, marked sampled: true. Also fixes queueWaitMs, which mixed clock bases (epoch enqueue stamp vs performance.now dispatch stamp) and always clamped to 0.
  • The GPU fx particle arena now has a budget/eviction policy (ledger #707). It used to be first-come-forever: populations claimed segments at create time and never released them while re-fireable, so a full arena silently rejected every later effect to the CPU fallback renderer for the rest of the session (12 of 16 zoo fx populations always ran the CPU path — the "mega trash" render quality was the fallback renderer, not GPU defects). Under allocation pressure (particle segments, program windows, or death-ring windows) the backend now PARKS the least-recently-spawning dormant effects — every population readback-confirmed dead, no death records pending, spawn-idle past FX_GPU_ARENA_EVICT_IDLE_FRAMES (same ~5 s cadence as render.ts's warm-park tier, so a parked effect's batch families are already warm-parked with material + texture kept) — releasing their arena allocations while keeping CPU spawn state (elapsed, burst timers, rng, persistent flags) intact. Active populations always beat parked ones; hot tenants are never evicted.
  • Parking is not a kill: a parked effect re-arms in place the moment any population wants to spawn again (burst due, persistent count pending, rate > 0) — fresh segments (possibly parking a now-colder effect in turn, LRU), program re-upload, coupling re-wired, and resetPopulationRuntime on every reclaimed range (#7603's corpse rule: stale alive flags from the previous tenant must be cleared before stepping). Timelines never restart and no spurious volleys fire; a frozen-at-due burst fires exactly one volley at re-arm.
  • Eviction and forced fallback are loud instead of silent: console.warn per event plus cumulative renderer counters on GpuFxStats.arena (evictions, exhaustionFallbacks, parkedEffects, residentEffects, freeParticles).
  • The WGSL coupling gate (scripts/verify-fx-gpu-coupling) gains an arena-pressure-eviction scenario: more population demand than arena capacity on a real WebGPU device — a hot whole-arena tenant refuses a competing create loudly, a dormant one is evicted, the death→child coupling chain runs corpse-free in the reclaimed ranges, and the parked tenant re-arms once pressure lifts.
  • Fixed GPU-path floor:"die"/floor:"stick" going permanently inert over heightmap terrain (ledger #711 — drops fall through the pad, die by lifetime 1–10 m underground in a ragged waterline, and every when(dies) child spawns occluded below ground, visually identical to broken coupling). Root cause: the fx terrain mirror's update-range overflow fallback (fx-gpu/terrain-heights.ts markDirty) cleared the pending updateRanges to enter three's "empty ranges ⇒ full upload" state, but the very next addUpdateRange in the same flush window re-entered range mode — so every cleared range (plus the triggering call's own) uploaded NOTHING. Any single sync wave touching more than MAX_PENDING_UPDATE_RANGES (256) chunks — a join burst, teleport, or LOD flip, with the nearest (pad) chunks synced first — left its first 257 chunks' chunk-table slots and height grids stale on the GPU forever while the CPU-side mirror map recorded them as resident, so sampleGroundHeightGpu read them as invalid and ground contact never fired. The CPU fx path samples the records directly and was unaffected. The fallback now collapses the pending ranges into ONE whole-buffer range instead of the resurrectable empty list.
  • scripts/verify-fx-gpu-coupling (real-WebGPU harness) grew the floor-contact-over-real-terrain coverage the defect hid behind (the existing floor scenarios used a synthetic sampler that bypassed the mirror entirely): a 3-phase standalone sampler probe over baked flat-generator chunk records (initial upload / post-first-dispatch late arrival / 300-chunk single-frame overflow wave), plus a zoo-neon-rain scenario driving the REAL mirror with terrain streaming in after the effect's first GPU frames — asserting drop deaths cluster AT ground height ± epsilon (death-ring readback), no live drop survives below the floor, and coupled children spawn at the waterline. Pre-fix these reproduced the live capture exactly (deaths at y≈−10.8, children meanY≈−6.3, 257 of 300 wave chunks invalid — the precise victim count of the range-clear). A vitest structural pin covers the CPU-side contract: after an overflow wave, every synced chunk's staging region must remain covered by the pending update ranges.
  • Hardened two edges in the fx warm-park tier (ledger #710, follow-up to #7607): (1) re-bake while parked — a parked family's bound texture id now carries a bind-time version stamp (provider getTextureVersion, new optional member on SpriteAssetProvider.service) that is re-checked at park lookup; a scripted re-bake/content swap while parked bumps the id's version and the old texels retire → dispose after the service's grace window, so a stale parked entry is now an honest MISS (dispose + rebuild through the async compile queue against the re-baked texture) instead of a warm hit serving pre-bake, soon-destroyed texels. (2) pending packs at park time — a family whose texture/pack never materialized (textureApplied false, 0 transferred bytes) is no longer admitted to the park: it disposed at the idle threshold before #7607 and does again now. Parking it claimed a residency slot (evicting real warm families oldest-first under FX_GPU_WARM_PARK_MAX_FAMILIES) while holding a 1×1 placeholder that could never materialize (no sync poll runs while parked), and its re-fire paid the texture round trip regardless.
  • GPU fx batch families that idle past FX_GPU_BATCH_IDLE_DISPOSE_FRAMES (~5s) are now PARKED warm — mesh hidden, material and texture retain kept — instead of disposed, bounded by a sized warm-park budget (FX_GPU_WARM_PARK_TEXTURE_BYTES = 16 MiB summed texture footprint, FX_GPU_WARM_PARK_MAX_FAMILIES = 64; oldest parked evicted first past either line). Disposal released the last same-WGSL material, so three's refcounted nodeBuilder/program/pipeline cache entries died with it, and the texture retain release let the 128 MB keep-alive budget evict the effect's sprite between fires — a trigger effect firing minutes apart (ledger #709: Eternal Blade's level-up on 5.1.8) re-paid the full cold path on the render worker every fire: TSL graph rebuild + WGSL codegen (synchronous inside compileAsync — #7343's async machinery defers the pipeline, not the node build), driver pipeline re-creation, a compile-hidden round trip, and a KTX2 re-fetch + re-transcode (off the render worker on KTX2Loader's sub-worker pool, but the effect fired white until it landed). The per-fire hitch was big enough to backlog the render channel into a stream reset — the creator-visible "whole game freezes and all my meshes reload."
  • A parked family re-firing into the same render window (the arena's LIFO segment-slot stack + first-fit range allocator reproduce it for a repeating effect) is a bind-identical warm hit: unhide the mesh, zero rebuilds, zero compiles, zero fetches. A re-fire into a moved/grown window still rebuilds honestly and rides the async compile queue, exactly as before.
  • spawn.when gains a count knob (ledger #703): when(parent.dies, { count: N, probability? }) spawns N children per triggering parent death (integer 1–32, FX_MAX_COUPLED_SPAWN_COUNT; validated with a loud FxProgramError beyond that — bigger asks belong to rate/burst populations). Probability stays a per-death filter — one roll per death, a triggering death always bursts the full count — and each child evaluates init/inherit against the same parent record with its own spawn randoms, so the shatter's shape lives in creator script math, not an engine preset. when(parent.dies, 0.5) (number arg) is unchanged.
  • CPU backend (spawnCoupled) is the reference oracle: per-death probability roll, then a count-deep inner spawn loop, still capped by maxParticles.
  • GPU backend: coupled over-dispatch is now perFrameDeathRecords × count, bounded by max(perFrameDeathRecords, childSegmentCapacity) — count:1 dispatch is bit-identical to before, and an unbounded count × death-ring product can no longer crowd later populations out of the frame's spawn map. The bound is a documented GPU-path capacity trade, not free: lossless at probability 1, but at probability < 1 under a single-frame death spike it truncates record coverage below what the CPU oracle delivers (e.g. 512 deaths × count:4 × probability:0.5 into a 1024-slot child covers records 0–255 only — ~512 children where the CPU delivers ~1024 inside the author's maxParticles); the GPU still delivers at least the bound's floor. New CoupledCount population-table field; the spawn pass maps coupled thread n to death record n / count (same parent window + probability draw per record, per-child variance via the localIndex spawn-RNG salt). Spawn-map truncation past the arena's per-frame thread budget now warns once instead of silently dropping later populations' spawns.
  • Gate extended and run: scripts/verify-fx-gpu-coupling grew a count-shatter-floor-coupling scenario on a real WebGPU device — a one-shot 64-heart volley floor-dies and must yield EXACTLY 64×5 children at the contact plane (no corpses, finite positions), with a probability-0.6/count-4 sibling arriving only in full 4-bursts. CPU-side zoo scorecard pins the clip-class semantics (a heart bursting into N shards) on the CPU oracle.
  • Ghost-mesh divergence (ledger #713): script-spawned objects existed in the spec and on the server but rendered nothing on a client, survived reload, and threw zero errors. Three fixes, one class:
  • Bespoke geometry self-heal (the real fix). A draw/mesh pointer (bespoke/<sig>) and its geometry value travel the render channel independently with no arrival guarantee; once the geometry op was lost under join pressure, the signature-equals gate (bespoke-geometry.ts) suppressed every later re-derivation of identical content — the renderer waited forever. The renderer now tracks unresolved pointers (three/meshes/geometry-wait.ts) and reports stalls up the existing relay (render worker → main → runtime worker, render.bespokeGeometryMiss); ecs-sync retransmits the LIVE draw/mesh + geometry values straight through the writer — a transport retransmit, no ECS write, so the equals gate never sees it. Wait-on-missing is now backed by guaranteed eventual arrival.
  • Loudness sentinel. The silent wait-forever in reconcileVisual (if (!geometryValue) return) now warns once per entity+signature after ~2s ("bespoke geometry never arrived: entity X sig Y"), raises the new log-only bespoke-geometry-stalled engine diagnostic (getLogs-visible, page-console forwarded for observability, running counters in the data), and re-reports on a 5s cadence to drive the re-forward until resolved. The mesh handler's store/wait sweeps now run on op-less frames too — the sentinel exists precisely for the op that never comes.
  • Join-blob same-tick cursor. EntitySnapshotBlobCache invalidation used a once-per-tick rangeSince(lastScanTick) scan (strictly-newer-than-tick): a replicated write landing later in the SAME tick as the scan never folded into the entry's dirtiness, so the cached join create-bundle stayed stale forever for that entity id (stale on every future join; survives reload, heals on new-id respawn — the same observed shape server-side). The scan is now a prune-tracked row cursor walked on every request with boolean per-entry staleness — same-tick ordering is exact by construction (every build happens with the cursor at the log's end), with prune-provability via newestPrunedTick().
  • 3d-sprites skill grows a "Buildings — Pieces on the Grid" section: sprite buildings are assembled from a modular piece family (floor/wall/roof at one module size, walls upright on cell edges with billboard: "none" + static physics, doors/windows as wall-piece variants, roof pieces tagged and hidden while a player stands in the footprint). This is the interim authoring pattern until the engine's native wall/roof tile layers land (ledger 730); the skill's phrasing survives that swap. Field-driven: a creator fought 3 days while Savi painted each building as a single sprite (dump 713aed61), and converged only after hand-writing this exact modular blueprint.
  • Suppressor tightening from the #7478 seam-check, verified against source: the Register now names the real cost of habit-copied 2D flags (getSpriteBatchKey returns null for any ySort sprite in ANY mode — a piece town becomes hundreds of standalone meshes, the observed 543–610ms frame spikes); 2d-mode's ySort bullet carries the same spend-it-only-on-interleaving caveat; Depth and Cutout teaches that script-baked textures (scripts/tex-*.js) are never pixel-inferred (isPixelInferredTextureId is a pure function of the texture id), so they opt into cutout: true explicitly.
  • Routing, on both surfaces (grouped skills' own descriptions are invisible in the manifest — the #7478 dig fact): the world-building group line in cf-studio-chat's SKILL_GROUPS now disambiguates mesh-built vs sprite-piece buildings, structures.md gets a body-level redirect + description update, and the ungrouped 3d-sprites hook gains "modular sprite buildings". 2d-mode's Isometric section gains the one-line piece discipline (band-sorted: floors/roofs batch, only walls ySort).
  • Backend MSAA is the default renderer anti-aliasing resolution again (DEFAULT_RENDERER_ANTI_ALIASING_MODE = "msaa"), restoring 5.1.6's msaaEnabled expression (baseline.boot.msaa && cuts.msaa !== false && !retinaDisplay) at default settings — desktop and tablet boot multisampled, phones keep their class msaa: false floor, Retina DPR-2 suppression stands. The SMAA default introduced in #7363 left every production session single-sample (renderer init never passes antiAliasingMode, so the "msaa" arm was unreachable), which silently disabled alpha-to-coverage engine-wide: creator cutout content rendered its transparent padding as solid black (first casualty: resx's spine rig).
  • SMAA and TAAU stay built and explicitly selectable via SessionParametersInput.antiAliasingMode — either post mode still disables backend MSAA (mutually exclusive by construction). A default-mode redesign lands in a separate controlled, well-tested AA fix PR.
  • The adaptive-quality governor's msaa-off rung is meaningfully reachable again: a landing's metered msaa cut boots single-sample per-device (it was moot while the default force-disabled MSAA everywhere). #7363's shadow-map changes are untouched.
  • preview_object's perf line now reports real per-frame GPU cost alongside CPU encode (ledger g-32 — scripted-material runtime cost attribution). The old line measured only cpuFrameMs, which is FLAT (~0.03ms) across a corpus whose real GPU cost spans 110× (fuzz shell 17.1ms vs noise-floor 0.15ms) — Savi shipped 17ms/frame materials believing them free. The fix wires the vendored three fork's existing GPU timestamps (backend.trackTimestamp + resolveTimestampsAsync, envelope semantics pinned by gpu-timestamp-frame-envelope.test.ts) into the preview rig: a new object-preview-gpu.ts sampler acquires the validated timestamp gateway (applyGpuTimingState — the same Metal-deny-safe path the inspector uses), runs a paced ~250ms busy warmup before the measured window (below that, Apple-silicon DVFS inflates sub-ms reads ~3×; warmup bursts await queue.onSubmittedWorkDone so live frames interleave instead of freezing), then samples a separate synchronous render leg (the CPU-timed leg's per-frame info.reset() would collide three's frameCalls-keyed query uids) and resolves per-uid pass durations — immune to the inspector's concurrent per-frame resolves. The perf line labels the number honestly: measured on this device at the preview's tile resolution, one point on the cost curve. Without a usable timestamp-query feature the line says GPU: unavailable (no timestamp-query) — never a fake number. The renderer worker re-asserts the inspector's requested GPU-timing state after every preview.
  • Added SCATTER_BUILD_INSTANCE_BUDGETS (ledger #664, the #191 lane) to src/engine/renderer/perf-static-data.ts: a static per-tier instance ceiling (200k desktop / 100k tablet / 50k phone) applied at decoration BUILD time in src/engine/renderer/three/decorations.ts, protecting the first frame from spec-authored counts the quality ladder can never measure (the build wedges the renderer worker before the governor gets a sample).
  • Under-budget builds are byte-identical (the allocator receives exactly the ladder budget); the ceiling binds only when the would-build count exceeds it, and deeper ladder rungs keep tuning below it.
  • When the clamp binds, a creator-legible line lands on the getLogs rail via the new log-only decoration-budget-clamped diagnostic code ("Terrain decorations clamped 500000→200000 instances (device budget); the quality ladder tunes from here.") — no DM, same device-local class as adaptive-quality-step.
  • Scatter bed placement now excludes the XZ footprints of voxel structure terrain marks (ledger #664 half B, the #191 through-wall lane): new resolveVoxelStructureFootprints in src/engine/features/terrain/voxel/marks.ts resolves the exact cell extents the structure builder claims (template bounds / resolveStructureBounds) to world-space AABBs — pure spec math, revision-cached, identical on both realms — and resolveScatterChildren rejects points inside a footprint plus the standard 1 m mark buffer, same as the existing river/pond/road exclusion.
  • Applies to both shape-bounds and painted (field-bounds) beds; measured worst case (500 points × 32 structures, all-miss) is ~11–17 µs per bed resolve, so the check defaults on.
  • Per-bed opt-out for intentional placements (overgrown ruins, props indoors): filter: { avoidStructures: false } on the scatter spec.
  • Out of scope, unchanged: GLB/model-object buildings and 3d-rooms terrain have no footprint declaration yet (the classification design the #7510 note flagged), and the GPU decoration-carpet path samples heightmap only.
  • Singleplayer live-edit divergence (ledger #718): an edited behavior script never took effect on an already-connected client — every Savi fix read as "still broken" to the creator until a full page refresh, while fresh joins got the new version. The disease is #7613's shape in the spec-push lane: applyWorkerSpecPush's staleness gate compared the pushed (server-minted) revision against the client's own counter — but revisions are minted independently per realm. The singleplayer authority bumps its copy on every LOCAL spec write (god-mode edits, behavior spec writes, committed run_scripts — the glued tome/spec-update), while the server's only bumps on foreign kiln applies (its own persists stamp dbVersion without a bump, and #7619's poke coalescing widens the gap further). Once the client counter ran ahead, every tome.spec.push — Savi's script edits AND revert pushes (ledger #296) — was silently dropped until refresh re-seeded the counter from the join snapshot.
  • The fix: gate on the kiln dbVersion, the one counter both realms share. tome.spec.push now carries the dbVersion the pushed spec was applied from (room-runtime → notifier → wire); the client applies when it's strictly newer than its own baseline (equal admits replace: true — the kiln-rejected-mutations rebuild demand, mirroring room-runtime's own guard, ledger #326). A dbVersion-bearing push over a client with NO baseline (degraded partial-hydration joins) adopts the kiln truth instead of dropping a foreign save by drifted counters; only pushes with no dbVersion at all fall back to the old revision gate. The stored revision always moves forward (max(pushed, live + 1)) so spec-sync can never skip an admitted push whose server-minted number collides with one the client already applied. resetTomeWorld now preserves the dbVersion baseline like every other TomeSpec writer — an in-game reset no longer blinds the gate.
  • Loudness sentinel. A suppressed push whose content hash differs from the live spec logs a [spec-push] dropped a stale spec push carrying different content warning (page-console/getLogs visible) instead of nothing; the healed lane (dbVersion admitting what the revision comparison would have dropped) logs one info line so field logs show the save arriving.
  • Spec-apply coalescing in the container's poke lane (ledger 714, fix half a): ContainerRuntimeProxy.updateTomeSpec now runs at most ONE apply per room at a time. A poke landing while an apply is in flight (or while the trailing window is open) answers 202 { ok, applied: false, coalesced: true } immediately and merges into one trailing fetch-latest apply, dispatched TOME_UPDATE_COALESCE_DEBOUNCE_MS (default 300ms, env-tunable) after the in-flight apply settles. Replace pokes OR-merge (a coalesced replace stays a replace — ledger #326's divergence heal), and the latest sdkConfig rides the trailing apply.
  • Collapsing is legal because applyTomeSpec always fetches the LATEST spec and the room's stale-echo guard (dbVersion <= existing → no-op) discards intermediate versions anyway: a 5-poke wisp burst that used to cost up to 5 × ~40ms synchronous applySpec hits on the sim tick thread now costs at most 2. Version monotonicity is the pre-existing guard, unchanged.
  • Provably invisible to the build lane: Savi/wisp save acks come from kiln's CAS write (save_game_spec_atomic in cf-studio-chat's save path), which completes before the poke is even sent; every consumer of the room /update response (kiln fanout, init-kernel) keys on HTTP status alone and drops the applied/revision body fields. The exec lane (run_script on the sim thread) is deliberately untouched — that is ledger 376's architectural thread.
  • Array-shape coherence for authored spec fields (ledger 715 — "Final Abyss" wipe, 4.5 min of wholesale apply failure): Savi generalized the API's record convention ({key: null} deletes, {key: patch} merges) to array fields, and both write paths stored plain objects where arrays belong — definePlace stored objects: {} verbatim, and patchEngine({crons: {0: null}}) blind-replaced the crons array with {"0":null}. Every subsequent applySpec then died with an unattributable TypeError: {} is not iterable (compiler.ts crons iteration, spec-assets.ts place.objects iteration). Two layers, matching write coherence with read fail-soft:
    • Write side: mergeSpecPatch now refuses ALL keyed-object patches on array fields — both the index form ({0: null}) and the stable-key form ({"scripts/a.js": null}) leave the array intact and emit a warning teaching the whole-array replacement form ({ field: [...] }). Array indices are not stable keys (sequential single-deletes would silently hit the wrong element after compaction), and every other keyed-map-onto-array surface in the API resolves by stable identity, so no positional surface is introduced. definePlace normalizes keyed-map/{} objects defs to arrays (ids from keys, updatePlace's convention — a def is a one-shot value, not a patch) and drops scalars with a warning; the recorded mutation carries the normalized def.
    • Read side: sanitizeAuthoredSpec repairs the poison class before anything walks the spec — objects, places.*.objects, engine.crons, engine.behaviors holding plain objects recover their entries (Object.values, keyed-null deletions dropped) with a loud getLogs+DM report naming each field; scalars drop the field instead of the apply. Belt-and-braces guards at the direct-call surfaces (compileSpec's cron iterations report engine.crons via onCompileError; collectSpecAssetEntries tolerates poisoned place.objects, scatter templates, decoration items, and null place entries — it's called from browser-host on raw specs).
  • New behavior API api.getWorldResidency(): { resident, pending } (ledger #717) — the renderer's own world-loaded verdict, finally readable from scripts. resident is a level, not a latch: it flips true only after every known real asset load (warm prefetches subtracted), terrain chunk install, and async material compile (queued roots, carry-over, compile-hidden objects, the router's deferred scene sweep) has drained and stayed quiet for RESIDENT_QUIET_FRAMES (90 consecutive renderer frames, ~1.5s at 60fps — asset floods arrive in waves, and an instantaneous flag would read true inside every gap); it drops the same frame new streaming starts (place transitions included). pending counts asset loads + terrain chunk installs for progress UI (compile tail work gates the flag but is not counted).
  • Residency is renderer-side truth on the machine asking: each client answers for itself. Server execution always returns { resident: true, pending: 0 } by documented convention (no renderer, nothing pending — mode:"both" scripts stay branch-free). Client answers are conservative-false until the renderer's first publish, and the renderer publishes nothing until its first authoritative ops ingest — a pre-content idle renderer can never report a world it knows nothing about as resident.
  • Plumbing: the verdict is fused per frame in the renderer worker from O(1) counters (new pendingModelCount on the asset service beside pendingTextureCount; the warmer's realPendingLoads now reads those counters instead of the allocating manifest-walking progress shape; new AsyncSceneMaterialCompiler.hasPendingWork() backed by a pending-object counter; new MaterialCompilationOpRouter.hasDeferredSweep(); a terrain install gauge written by the terrain handler's collect) and posted change-only up the existing renderer→main→runtime relay (resident edges immediately, pending-count changes on a 250ms throttle) into a module-level sim-side seam (tome/world-residency.ts, the preloadAsset determinism pattern — zero ECS-world trace on either side).
  • Auth-failure (1008 "unauthorized") WebSocket closes now count against a separate 3-close budget in ClientRoomRuntime (ledger #716): each 1008 is still retried with a URL refresh (getUrl), so a host that can mint a fresh token recovers, but a client stuck on an expired JWT stops after 3 closes and surfaces the existing terminal connection state (onConnectionFailed / onRetriesExhausted) instead of hammering verify every ~1.3s forever.
  • Retry budgets no longer reset on socket open — the server accepts the upgrade BEFORE token verify (network-worker mounts the room pre-auth), so an open proved nothing and made every failing cycle "attempt #1". Budgets now reset on the first server message of a socket (the server only sends to attached, post-verify connections: SessionWelcome immediately, heartbeats every 2s), which is the authenticated open.
  • Recovery signals (visibility/online/pageshow → requestImmediateReconnect) re-arm the auth budget along with the general one: a returning user reconnects immediately, with the fresh token their host can now supply.
  • WebSocket reconnect-success telemetry (recordWebSocketConnected) now fires on the first server message — the authenticated open — instead of the pre-auth socket open (review follow-up to #7626): in an expired-token 1008 loop every retry reaches onOpen before failing verify, so the old call site scored those retries as successes and closed their downtime samples, hiding exactly the loops ledger #716 is about. Perf rollups now report auth-failure loops as failures/abandonment, never false successes.
  • De-armed the stream-reset death spiral (ledger #735, the GPU-death root): ecs-sync collapses a >1s render backlog into one stream reset + full-world snapshot, but the reset barrier cleared on the tiny reset control frame while the snapshot's own decode+apply ran past the 1s threshold in chunk-heavy worlds — frames piling up during the apply armed the next snapshot from inside the current one, snapshot-per-second forever from a single stall (tab-hide, spec push): climbing memory, hot GPU, eventual crash. RenderChannelWriter.hasUnconsumedSnapshot() now tracks every world snapshot until the reader has drained the queue back to empty (consumer turn counters: the reader stamps a drain turn per drainOps entry, the render worker calls markApplied() after its op handlers run); while it holds, the stale trigger stands down to a 30× dead-reader bound (UNCONSUMED_SNAPSHOT_STALE_GRACE), so a hidden/wedged tab still collapses, bounded. Invariant pinned by test: one >1s stall → exactly ONE recovery snapshot → normal streaming resumes → the trigger re-arms for genuinely new stalls. Collateral deslop: the discard-only replacePendingFramesAfterStreamReset dies — sim-side tail rewrites carry their own in-stream reset row, closing a ghost window where entities despawned between rewrites survived a superseding snapshot.
  • Identical-content gates on the same flood paths (diet, not cure): particles/emitter and draw/light components gain deep equals at the settled write-site gate — dotted property writes rebuild the whole nested spec per call, so per-tick same-content writers (a campfire's ember spray, a lamp's glow) pushed identical values through replication and the renderer at 30Hz past the shallow default. And terrain/place-render-config now uses its configHash at setTerrainPlaceRenderConfig: a re-delivered byte-identical config returns no chunks (nothing enters the install queue, no slot re-binds) instead of re-processing every terrain chunk of the place. Content that genuinely changes per tick still flows exactly as before.
  • seconds() called with no argument (or a non-finite one) now returns the current game clock in seconds (getTick() / tickRate) instead of silently returning 0 — scripts read the name as a clock, so every duration computed from it froze (eternal campfires, cooldowns that never advanced). seconds(n) keeps seconds→ticks converter semantics exactly for finite args, both pinned by tests. Docs regenerated from source (TomeAPI.md, api-reference skill); zero added prompt tokens.
  • Killed the per-tick terrain-collider readiness sweep (ledger #740): physics/simulation-state derived readiness by walking every TerrainChunkCollider entity every server tick plus a per-anchor gate-chunk computation (voxel surface sampling over the place's edit list) — 37–56ms × 19 ticks/sec on Boom Island (300–630ms avgTick), the #1 prod server_behind variant at 42% of episodes. Readiness is now an event-driven dirty-flag index (server-collider-readiness.ts, the terrain-dirty.ts pattern): component hooks on TerrainChunkCollider / TerrainChunkKey / PlaceMembership mark dirty entities, reads drain the dirty set — steady state examines ZERO entities, an edit burst re-examines exactly the rebuilt chunks. Committed bench (bench-terrain-readiness.ts, Boom-Island-shaped fixtures): ~200–750× on 3k–20k collider fixtures, steady and burst.
  • Gate-chunk computation now runs only while the unpause decision is live (paused, forcePhysicsPaused, pre-first-unpause); once hasEverUnpaused is set it can never re-pause, so steady state skips it entirely. Full-sweep escape hatch markServerTerrainColliderReadinessStale fires on terrain definition reinstalls / place-mode flips (same trigger as markTerrainPlaceDirty), and a spec defaultPlace change is detected at drain time. Place attribution (PlaceMembership → spec defaultPlace → "main") is pinned to the old sweep's semantics by test; the client readiness path is unchanged.
  • Tome-UI overlays no longer leak native browser chrome (ledger #751): the kernel suppressed contextmenu per-surface (canvas via createDomManager, touch long-press) but #tome-ui-container was uncovered — right-click on any creator HTML menu opened the browser context menu, and default text selection + image drag-to-save leaked too (specimen: badgerblunts/Manstrosities, where a wisp had to inject oncontextmenu guards on 9 phase roots + user-select:none styles). Two engine defaults now cover every game: (1) mountPostedTomeUiDomHost attaches one contextmenu → preventDefault listener on the overlay container (game + creator targets) and on the full-viewport #tome-creator-mount lifecycle container — propagation untouched, so creator-authored custom context menus keep firing; world right-click (BIND et al) is unaffected because pass-through clicks target the canvas, never the overlay subtree. (2) buildTomeUiBaseStyleRules ships user-select:none/-webkit-user-select:none on the overlay containers only (descendants resolve auto against them, so Tailwind's select-text re-enables selection per element and input/textarea keep selection for free via contain) plus -webkit-user-drag:none on overlay imgs, exempting draggable="true" so intentional HTML5 drag-drop UI keeps working.
  • Game-target UI lifecycle: ui.render named exports onMount/onDestroy now mount on the main thread via the posted dom-host path (previously creator tabs only), with a content-versioned mount key — script edits remount live; mounts survive HUD visibility toggles.
  • WebAudio hatch (engine/audio/hatch.ts): lifecycle modules get a module-scope audio global — the engine's real AudioContext (proxied: destination remapped under the master bus, close/suspend inert, sources tracked), per-bus GainNode gates (user volume/mute + AudioPolicy govern creator audio structurally), and an awaitable ensureDecodedBuffer lane in the shared decode cache (forces buffered decode past the stream heuristic).
  • Engine-owned teardown: unmount/remount/host-dispose stops tracked hatch sources and disconnects all gates — the detached-looping-audio bug class (ghost chorus, ledger 698) is structurally impossible for hatch audio.
  • Worker realm binds audio to an inert absorber so dual-realm module top-level code renders safely.
  • Taught surface: new audio skill (three planes + hatch wiring); musicShift/ambience/stopSound/onSoundEnd docstrings now carry legacy pointers; game-ui's DOM <audio> guidance narrowed to <video>.
  • Re-fold, ledger 771 ch.2 (boot-loop fix): the previous fold's build (e41baeec…, generated 08:40:19Z from e1cc37bbb, two minutes after #7733 merged) carried the compile-hidden frame rail with its co-primary 60-frame deadline but NOT #7739's backstop fix (merged 11:28Z) — fresh 5.1.9 staging apps boot-looped in a ~500ms initializing→loading→starting crash-restart cycle. This entry now pins the 11:33Z build from 94599207b (#7739's merge commit): the frame rail is a pure backstop (180 frames; the 1000ms rail governs, pinned by a simulated-165Hz test), and #7735's client fixes (game-ui mount-key drift recheck + fault-rail report, audio.inspect(), ui-realm-aware reference validation, audio: null spec nullability) ride along.
  • Re-fold, ledger 771 layer 8 (curtain-gate fix): the previous pin (736ba4ea…, built 11:32Z from 94599207b) predates #7752, which makes the loading-curtain gate immune to signal ordering — scene.readyHint is now a re-post-until-acked channel (scene.readyHintAck) instead of a one-shot the host could lose to an attach-after-fire race, the renderer worker stamps firstFramePresented on every steady-state post so a lost one-shot ready re-derives from the stream, and the gate flags are inspectable via __spawnCollectSceneGateSnapshot. This entry now pins the 16:00Z build from 07076eb31 (#7752's merge commit), verified by content: scene.readyHintAck + __spawnCollectSceneGateSnapshot in index.js and scene.readyHintAck in the client runtime worker (all absent from the old build), firstFramePresented stamped in worker-renderer.mjs, with #7739's backstop rail (bSQ=180,hSQ=1000) and #7690's #tome-game-mount still present in both.

Engine v5.1.8

Released July 2, 2026

  • Side-scroller levels with visual-only heightmaps no longer get fake ground snaps or repeated collider rebuild warnings.
  • Color-grading looks that adjust saturation build correctly again instead of failing with a shader error.
  • A broken or still-compiling look can no longer darken your world: the screen always shows your plain, correctly-lit scene until the look is healthy, and Savi still gets told what failed.
  • Games can now author their render resolution: api.patchEngine({ graphics: { renderResolution: [480, 270] } }) renders the whole game at 480×270 and upscales to fill the screen — retro/PS1 looks, pixel-perfect low-res, or a performance cap for heavy scenes. It applies live (no reload), acts as a ceiling (players' devices can still auto-reduce under it when struggling, and it never exceeds their native resolution), and renderResolution: null removes it.
  • Player progress now saves when a stuck world update forces a room restart — players rejoin the fresh room with their latest progress instead of losing everything since their last save.
  • A model can now sit offset from its physics body: model: { id, offset: [x, y, z] } slides the rendered mesh in meters along the entity's local axes — no more child-entity workarounds when a model's art doesn't line up with its hitbox (e.g. an avatar mesh 1m behind its capsule).
  • Paint parts of a model: model: { id, materials: { PartName: { ... } } } layers color, metalness/roughness, opacity, and glow onto one named part of a GLB while the rest keeps its built-in look — make the blade glow without touching the grip, light the brake lights, tint one building's windows. Typos in part names warn in logs with the names that would match.
  • Switching a game into a different built-in camera style now changes both its framing and movement behavior.
  • Blueprint placement is more reliable across larger multi-area games.
  • God-mode labels and status text (like "Generating…") stay readable while the UI is still loading.
  • Switching in and out of god mode keeps player placement more consistent.
  • Games built from many scripted shapes load smoothly when you join — no more lag spiral or briefly vanishing objects in scripted-geometry-heavy worlds.
  • God mode gets its 10x pass: pressure-feathered brushes with a two-ring cursor, snapping, slider widgets, an fx shelf (seeded firework shows, rain that streaks along its fall, layered mist), spline and spotlight feel, emitter shape wireframes when selected, and a library plus genre pages.
  • Screen effects no longer flash the whole screen when they turn on.
  • Blur and desaturate effects actually work now — and looks with saturation (noir, underwater, vhs) stop washing toward white.
  • Re-triggering a timed effect no longer cuts it short.
  • Regenerating scripted shapes stop blinking (and their shadows stop flickering).
  • Plug in a controller and the right stick looks around in every game.
  • Music that leaves the screen actually stops: no more invisible ghost copies stacking up behind your soundtrack.
  • The new anti-aliasing path is stepping back to opt-in while we fix sprite ghosting and material fade — visuals return to the previous default
  • Brushes got a feel upgrade: a new Feather control (hard edge → soft airbrush), and the brush cursor now shows two rings — solid where you're painting at full strength, dotted where the effect fades out.
  • The brush remembers your radius, strength, and feather per tool now — no more re-dialing every time you pick a brush back up.
  • Sliders! Brightness and volume (and anything Savi builds for you) can now be dragged smoothly instead of picking from presets.
  • Buttons Savi makes for you are more reliable: broken ones can't take down the rest of your editing tools anymore, and missing icons show a letter instead of nothing.
  • God-mode icons are getting a fresh, consistent look (they regenerate as you play — a few may take a moment to appear the first time).

technical notes

  • Terrain rescue and client collider parity checks now stand down for visual-only heightmaps in 2D places instead of creating fall/snap and impossible rebuild loops; supported voxel-slice and 3D terrain behavior is unchanged.
  • Color-grading looks using grade() with a saturation key build correctly again (ledger #661). The TypeError: getNodeType is not a function TSL build crash lived in the r0.184 three fork that engines 5.1.5/5.1.6 shipped; the r185 re-vendor replaced those TSL internals, and the creator's exact repro and bisect matrix (exposure+saturation, saturation-only, the full grade→vignette→grain chain, the script+vocabulary composite, and a two-builder rebuild) are now pinned through the real WGSLNodeBuilder so the documented grade surface cannot silently regress.
  • A look whose final full-screen draw cannot land no longer darkens the world (ledger #662). Nonblocking shader compilation silently skips draws with cold or never-compiling pipelines; the look pass now detects its own skipped present via the fork's compilationSkipCount (the same guard the engine chain's presenter uses) and presents the plain un-graded frame that same frame, and it pre-warms the copy material's pipeline at construction so the fallback is never itself cold. Any look build failure now presents the scene exactly as if no look were authored, alongside the existing diagnostics.
  • New spec vocabulary engine.graphics.renderResolution: [width, height] — an authored drawing-buffer ceiling. The client syncs it into a forwarded draw/render-resolution singleton (spec-sync → render channel), and the renderer folds it into effectivePixelRatio as a cap on the existing DPR plumb: the buffer renders at the largest size that fits inside the authored pair at the viewport's aspect (exact on matching-aspect displays), the browser upscales to fill the canvas, and the quality ladder's renderScale multiplies after the cap so the governor reduces under the authored ceiling and can never exceed it. Never raises above the device's native/tier-clamped resolution. Live on patch; unauthored specs are byte-identical to before.
  • The wedged-apply container recycle (tome.update RPC timeout, ledger 649) now runs a bounded graceful dispose before exitContainer(1): the container posts dispose to the still-healthy sim worker and waits up to 5s for disposeComplete, so saveAndDispose → firePlayerDisconnectedHooksForShutdown lands connected players' saves like a SIGTERM shutdown would; past the bound it exits hard exactly as before.
  • The mount-fallback roomReady flip in room-runtime.ts now clears the 45s boot-liveness deadline, so no dead timer stays armed after the room becomes ready.
  • Primitive batch lanes reuse freed geometry arena ranges (#7518): regenerating scripted primitives (params ticking every second) no longer grow the merged arena monotonically — the wholesale buffer reallocation at every capacity crossing, the per-revision geometry-id churn (indirect draw buffer replacement), and the stream-reset feedback loop all stop. 40-revision regression pin.
  • The look-activation flash is dead by invariant (#7525): every frame presents through warm pipelines — cross-target topology flips (vignette/effect/slowMo on) re-present the last warm path until the look's pipelines land; LookPass reports landed draws; prewarm overlaps compiles with covered frames. The r185 luma-dot white-wash is fixed (dot promoted vec3 weights to vec4 with alpha=1.0, so every authored-saturation look mixed toward super-white — WGSL-shape pins prevent recurrence); blur and desaturate join the look vocabulary (separable gaussian, radius rides a uniform so fades never rebuild the graph); effect(duration) re-press is anchor-guarded.
  • Right-stick camera look works in every game (#7458): the stick writes the same 120Hz camera-orientation channel as mouse and touch, so authored sensitivity/clamps govern identically; the pad poll ignites on gamepadconnected and parks at zero pads — zero per-frame cost until a controller exists.
  • Media elements removed by tome-UI morphs or teardowns are unloaded (#7515): a discarded playing <audio>/<video> is paused and released instead of playing forever detached (Chrome GC-protects playing media) — the ghost-chorus bug class (stacked invisible music copies) is dead; persistent same-id players survive re-renders untouched.
  • Bespoke scripted geometry and its textures are content-addressed resources now (#7412): join-time no longer re-normalizes and re-hashes every byte of every scripted mesh on every encode/decode, and a stream reset no longer disposes and cold-rebuilds every bespoke visual — killing the self-sustaining join lag-loop in scripted-primitive-heavy games (and the disappearing-mesh reports that were the same loop from the other side).
  • model object form gains offset?: [x, y, z] (ledger 672): a render-only translate of the mesh relative to the entity's transform, composed as entity · T(offset) — after rotation (including any pivot fold) and entity scale, before the geometry-fit scale. Plumbed through the spec types, zod schema, curated property validator, DrawModel component + wire codec (new flag bit; absent offset is byte-identical to the pre-offset stream), and all three renderer representations (static batch, horde batch, skinned clone — including bone-pose bases, so bone-attached children follow the offset mesh).
  • Offsets quantize to f32 at the source (quantize.ts contract) so client-side setProperty('model') writes match the decoded authoritative value bit-exactly — no phantom prediction drift rows.
  • Physics, colliders, culling/LOD, nameplates, cameras, and everything else anchored to the ENTITY deliberately keep the entity origin; the offset moves only the rendered mesh (pick/outline follow the mesh since they derive from it).
  • ObjectProperties.model object form gains materials?: Record<string, StandardMaterialSpec> — the writer half of per-submesh material overrides (structural-gap audit #3, ledger 678). Entries normalize through normalizeMaterialSpec into the already-shipped DrawModel.subOverrides field (wire codec + model-batch consumption existed with zero producers; derive-appearance.ts now carries the map instead of never writing it), keyed by the GLB's material names with node name / primitive id as the consumer's fallbacks. Per property the renderer resolves subOverride ?? whole-model override ?? GLB value.
  • A model written without materials keeps the exact legacy component value (no subOverrides key), so existing specs replicate byte-identically.
  • Part names that match nothing are gracefully inert and now report model-part-material-unknown (once per entity+key-set, listing the model's real material names); a materials map on a rigged model reports model-part-materials-skinned — the static render path is the consumer.
  • The dormant inert-scatter instanced path carries the map too: InertScatterTemplateSource.subOverrides → DrawModelInstances.subOverrides (the instanced draws feed the same ModelVisual pipeline), so a scatter template painting parts keeps them if/when INERT_SCATTER_INSTANCED_PATH flips back on.
  • Runtime setCamera() now resolves behavior from the final camera config: built-in kinds activate their matching behavior, optional script overlays remain composed in order, and replacement cameras stay replacement-rooted through later cursor/FOV patches.
  • Fixed TAAU motion vectors for all positionNode-driven content (heightmap terrain, voxel buckets, decorations, water): the previous-frame basis is now the same deformed position reprojected through previous matrices, instead of the raw geometry attribute that made terrain velocity garbage and rejected history every frame.
  • Skinned characters carry real previous-frame bone data for their motion vectors (per-object useVelocity opt-in; vendor patch 0009 exports three's object-data registry), so animated characters stay sharp under TAAU instead of smearing.
  • Deformation-aware blend boost: pixels whose motion disagrees with a static-scene camera reprojection (limbs, movers) converge toward the current frame in a few frames instead of smearing across the full accumulation window; camera-only motion is unaffected.
  • Persisted the TAAU thin-feature lock channel across frames (two-attachment resolve target; lock read follows the reprojected history UV).
  • RCAS sharpening now runs in reversibly compressed space with the noise limiter enabled, so HDR highlights and residual instability are no longer amplified.
  • Replaced fixed nonlinear-depth disocclusion thresholds with FSR2-style view-space, depth-proportional tests (Akeley separation bound).
  • Jitter sequence length now follows the upscale ratio (8 × area ratio, FSR2/DLSS prescription) instead of a fixed 31-phase cycle.
  • History resampling upgraded from bilinear to 5-tap Catmull-Rom.
  • Added a Velocity debug view to the renderer inspector (Debug view → Velocity) presenting the motion-vector MRT directly.
  • Fixed god-mode blueprint toolbar values in src/engine/ui/tome/toolbar-overlay.ts and src/tome/api/object-api.ts so object choices stay scoped to their source place.
  • Fixed blueprint subtree collection so scoped object IDs and authored object IDs both resolve during placement.
  • Added an inherited color + text-shadow base rule for #tome-creator-ui-container in src/engine/ui/tome/tailwind-cdn.ts so god-mode text is readable in the boot window before the lazy Tailwind runtime compiles utility classes; compiled utilities still win via inheritance.
  • Fixed god-mode exit/re-entry in src/engine/runtime/server/commands/god-mode.ts by clearing stale BodyPosition state when restoring the parked god-mode entity.
  • SMAA is the default renderer anti-aliasing path again (DEFAULT_RENDERER_ANTI_ALIASING_MODE = "smaa-1x"); the mode union is widened back to "msaa" | "smaa-1x" | "taau" so TAAU stays built and selectable (SessionParametersInput.antiAliasingMode) instead of being the only representable value. Reverted because live tasting failed the TAAU default: heavy temporal ghosting on moving pixel-art sprites and material fade at distance.
  • Every desktop session — discrete and Apple-silicon — resolves the pre-TAAU defaults: native scene scale, the class shadow baseline (no integrated→medium shadow-floor demotion), SMAA at the encode seam. The "integrated desktop" heuristic classed M-series Max machines as weak silicon; an honest Apple-aware tiering pass will re-introduce scaling where it belongs.
  • The scene pass is a plain native-scale single-target pass outside TAAU; under opt-in TAAU it keeps the velocity MRT + sub-native sceneResolutionScale (TAAU remains the one shipped upscaler, so a sub-native scale without it is ignored). All #7422 velocity/lock/RCAS fixes stay in the tree.
  • Restored the Retina MSAA suppression and the msaa-mode boot fact (displayPixelRatio input), and the vendored SMAA node deleted with the TAAU default.
  • The "medium" device shadow tier and its inspector label stay as vocabulary; no class baseline resolves to it today.
  • Brushes: new optional feather (0–1, fraction of radius that fades at the rim) on BrushDef, armBrush, and the brush session; ctx.falloff now shapes by feather (feather 1 ≡ legacy full-radius smoothstep — terrain sculpt brushes unchanged). Material brushes default feather 0.25 (Crisp), scatter 0.5 — every family default sits on the Feather chip's option list so the strip highlights on arm. Feather chip (Hard/Crisp/Soft/Softer/Airbrush) in the brush action panel.
  • Brush cursor is now two rings: solid at the half-strength contour, dotted at the true rim. Tile brushes keep the single hard ring.
  • Brush defaults snapped onto the chip option lists (material 4.5→5, paint-with-prop 6→5); scroll steps are proportional (radius ±max(0.5, r·0.12), strength ±max(0.05, s·0.12)); per-creator brush tuning (radius/strength/feather) now persists across re-arms on TomeGodMode.
  • Action chips: new optional icon slug (normalized via the shared moodboard convention, letter fallback on 404 — no more invisible chips), disabled, active, and persistProperties on click verbs (non-envelope property writes now persist + undo).
  • New slider chip: slider: { min, max, step?, value, apply, format?, persistProperties? } on ActionHandleSpec — chip opens a slider drawer (desktop) / expanding sheet row (touch); replicated god:slider action with drag/commit/cancel phases riding the standard gesture undo bracket (pointercancel reverts the preview; a reaped or orphaned drag commits its last previewed value, so no abandonment path leaves the bracket open). Declared ranges are validated at publish — non-finite/inverted ranges render as a plain chip. Light Brightness and audio Volume default editors converted from preset menus to sliders.
  • Editor fault isolation: a throwing creator editor() layer no longer strips the whole authoring surface — the faulted layer is script-scope parked (DM rail intact), default handles keep composing.
  • Menu option pre-rendering memoized (was re-rendered per option per sim tick); one-UI-frame TTL + commit-funnel invalidation.
  • God-mode icon moodboard consolidated to one exported constant (MCDN_ICON_BASE) and bumped to moodboard-solid-flat-ui-icons-v2, forcing every icon to regenerate under the current tight-crop pipeline; the renderer insets the glyph to 78% of the icon box for assets from that board ONLY — pass-through URLs (legacy v1 icons pinned in specs/recents, foreign art) keep the old 100% contain, since they carry their own margins and would double-pad.

Engine v5.1.6

Released June 26, 2026

  • Large terrain worlds spend much less CPU maintaining stable physics colliders.
  • Weapons and other items attached to animated characters no longer jitter or warp while the character moves.
  • Objects you add or delete in GOD-MODE now stay added or deleted in multiplayer worlds.
  • Custom events triggered by interactions now work reliably in multiplayer, including while players join, leave, or host simulation changes hands.
  • A broken UI handler now leaves the last working UI in place and tells Savi which UI script needs repair.
  • Sprites now appear reliably during startup even when camera setup changes while their shaders compile.
  • Preloaded voice lines and sound effects now play immediately even when their optimized audio version is still being prepared in the background.

technical notes

  • Made client terrain-collider tracking change-driven: collider installs, removals, despawns, runtime resets, and place cleanup now update readiness indexes directly. Physics ticks poll only unresolved installs, with a low-frequency full audit for recovery. In the captured large voxel world, tracking CPU fell by 93-98%.
  • Fixed bone-attached props receiving a second synthetic transform interpolation pass after their world transforms had already been derived from the displayed rig pose.
  • Fixed client-auth GOD-MODE structural edits so persistence trackers capture and save object spawns and deletes through the authoritative room sink, while the accepted batch immediately advances the room's live game spec.
  • Fixed client-auth custom event delivery by resolving ready clients through their durable session owner instead of the cleared join handoff field.
  • Preserved interaction-scoped authority for api.emit() calls when a place host changes while a forwarded onInteract hook is running.
  • Limit the iframe shell's generic window-error startup boundary to loading and evaluating index.js; after import, the engine's explicit startup producers and permanent client-error forwarding rail retain ownership.
  • Validate inline Tome UI event handlers before mounting rendered HTML, reject a render containing a malformed handler, and report a script-attributed ui.render fault instead of silently rewriting the authored UI.
  • Kept post-processing scene render targets alive until asynchronous WebGPU material compilation finishes reading their color and depth attachment descriptors. Camera or scene graph rebuilds can no longer invalidate depthStencil.format during pipeline creation.
  • Optional Ogg Opus audio companions no longer park the logical clip when their Magic CDN encode is still generating or unavailable before the CDN session is established. The loader starts companion generation, downgrades that clip registration, and immediately fetches and decodes the authored MP3, preserving preloadAsset() readiness while retaining Opus on later sessions.

Engine v5.1.5

Released June 23, 2026

  • Bloom is now much more performant.
  • Finished combat effects are cleaned up correctly in multiplayer games instead of accumulating and slowing the room down.
  • Custom event listeners and sound-finished hooks now work on player-owned and shared-room objects.
  • Visiting players can interact with and drive hosted vehicles with the correct mounted state and follow camera, without the room rejecting the handoff as not_simulator.
  • Games on Retina displays are faster.
  • Shadows are cheaper for inactive lights.
  • Custom paired controls now follow the order you write them, so reversing a pair reliably reverses the control.

technical notes

  • Replaced Three.js BloomNode with a fixed-topology Dual Kawase bloom pyramid, reducing bloom render passes from 12 to 9 while preserving live strength, radius, and threshold controls.
  • Moved runtime FX reaping onto each entity's active simulator so client-auth owners and place hosts publish completed-effect deletes through the normal state-delta lane.
  • Moved onSoundEnd tracking and dispatch onto the active simulator instead of the server replica.
  • Added server-sequenced, place-scoped custom-event fanout so client-auth simulators can invoke their local api.on subscriptions.
  • Made vehicle/control handoffs server-canonical, with two-phase A→B switching that uploads the old target's onControlEnd cleanup before the server atomically grants the new target and runs onControlBegin on its simulator.
  • Fixed severe WebGPU frame-presentation stalls by avoiding 4x scene MSAA on Retina DPR-2 displays, where native pixel density already supplies edge smoothing.
  • Skipped PCF filtering and transmitted-color texture sampling for resident shadow lights whose runtime intensity is zero, while preserving stable lighting pipelines.
  • BREAKING: Changed keyboard axis polarity from a hidden key-name lookup to positional negative/positive pairs. input-config.ts now assigns alternating -1/+1 scales to slash-separated keys, so arbitrary pairs such as f/r behave predictably and reversing the string reverses the axis.

Engine v5.1.4

Released June 22, 2026

  • Performance improvements for spoin animations also make games run faster.
  • Sprite-heavy games no longer freeze when attacks, effects, or animations reveal a new sprite texture for the first time.
  • Optimized the performance of debug instrumentation.

technical notes

  • Kiln now lazily mounts the global SpoinBurst WebGL canvas and renders it on demand only while burst or implode particles are active, instead of clearing and compositing a full-screen transparent canvas at display refresh on every page.
  • Active spoin solids use one instanced physical-material draw and their original edge outlines use one dynamically batched line draw, while per-instance color, emissive, roughness, and opacity preserve the original appearance.
  • Unified static and animated sprite textures behind a stable texture_2d_array shader graph, so texture and animation changes update bindings and uniforms instead of rebuilding sprite shaders during gameplay.
  • Improved asynchronous material warming and version tracking for sprites, text, primitive lanes, hidden visuals, and the live HDR render target; an unexpected pipeline miss now delays that visual instead of blocking the frame.
  • Stop hidden renderer debug panels from polling at frame rate and throttle display-only simulation CPU telemetry to 10 Hz.
  • Avoid republishing unchanged camera transforms, tick-rate components, and camera mailbox views every visual frame.

Engine v5.1.3

Released June 22, 2026

  • Savi can now notice and help fix visual effects, interface panels, sounds, and other game behavior that fails only on a player's device.
  • Games with lots of changing primitive shapes render more efficiently.
  • Savi now builds more consistent 2D character animations without swapping the character's visual identity between movements.

technical notes

  • Restored client-realm behavior fault reporting by routing effect, creator UI panel, vibe audio, and client-side applySpec failures through the engine.diagnostic rail to Savi.
  • Coalesced primitive batch buffer updates in IndirectBatchedMesh so repeated changes are uploaded together instead of issuing redundant GPU buffer updates.
  • Updated the 2D creation skill and animation examples to keep one sprite identity while expressing motion through mixer clip slugs.

Engine v5.1.2

Released June 19, 2026

  • Scripted materials no longer lose their shading after reloading a game.
  • Players now recover normally when terrain collision rebuilds instead of continuing to fall through the world.
  • Games with lots of coins, enemies, and other objects stay responsive when scripts look for players.

technical notes

  • Re-derive the complete client material, texture, and look script libraries when a projection reset recreates tome/spec, including refs from directly ingested runtime materials.
  • Preserve the renderer's last complete script libraries across transient component removals, while sending an explicit empty replacement when the reset spec has no live refs.
  • Fixed terrain chunk rescue recovery after a missing client collider rebuilds by letting verified support collision clear script-owned character fall velocity before the next physics step.
  • Fixed multiplayer client getPlayers() and other tagged queries to use the replication-maintained tag index instead of scanning every positioned entity.
  • Preserved dynamic player and spawned-object tag entries across client spec synchronization.
  • Treated declared places with no tagged entities as empty indexed results instead of full-scan fallbacks.

Engine v5.1.1

Released June 19, 2026

  • Room-based multiplayer games now recognize the correct room on every player, so shared lobby counts, match progression, and room controls keep working.

technical notes

  • Fixed ObjectAPI.getRoomId() in client-auth place hosts by carrying the selected room ID through the socket-open ingress event and installing it in the client world before the first room snapshot applies.

Connection: engine v5.1.0

Released June 18, 2026

  • Sound effects load reliably the first time you trigger them — no more silent first clicks while assets warm up, and sounds that haven't been generated yet now cook themselves instead of going quiet forever.
  • Game sounds and music download much smaller on browsers that can play Opus — same audio, faster loads. Nothing changes about how sounds get added, and browsers without Opus support keep getting the mp3 automatically.
  • Multiplayer got a major upgrade under the hood. Your own moves, hits, and actions now happen instantly and never rubberband — and worlds stay smooth even when the server is busy or more friends pile in. Nothing to change in your game; it just works.
  • Walking across big worlds no longer causes constant background terrain rebuilds — smoother frames while exploring.
  • Ground and collision now build reliably even when asset processing is backed up — fixes falling through the world (or bouncing in place) after exploring while models were still generating.
  • Animated material effects no longer cause frame hitches while they play.
  • Fixed controls staying dead even after the engine tried to recover the connection — completes the 5.0.15 input fix.
  • Fixed controls staying dead on a long session even after the engine tried to recover — completes the input-recovery fix.
  • Animated material effects now update instantly with zero hitches — param changes never rebuild shaders.
  • Objects that use a custom material now pop in smoothly the first time they appear — the engine compiles their shaders ahead of time in the background, so there's no stutter when a new one shows up.
  • When the game server falls behind, players now see a "Server running behind…" notice at the top of the screen — if you spot it, tell us! It means lag is coming from the server being overloaded (often heavy onTick scripts or huge object counts), not your connection, and the report helps us find it fast.
  • Long-running game servers no longer slow down over hours of play — fixes worlds getting laggier the longer a room stays up.
  • Enemy waves and crowds no longer freeze the game when they spawn — crowd shaders compile once and get reused.
  • Saves, inventories, settings, achievements, and progression now follow the logged-in player identity more reliably across joins and rooms.
  • Existing games that wrote saves under player/... keys need their scripts migrated to user/${objectApi.userId}/....
  • Fixed worlds where ground, water, or shadowed objects could turn invisible while editing — a texture set to pixelated (nearest) filtering, or a depth effect without its compare mode, could silently knock out the whole shader for everything drawn with it. Those materials now draw correctly instead of disappearing.
  • Other players can see your shots and visual effects in client-authoritative multiplayer games.
  • Recovering from a long stall now skips old gameplay history instead of rewinding and replaying movement and effects.
  • Characters in new 3D games now face where the camera is looking, including while strafing or moving backward.
  • Savi now preserves correct character facing when building first-person cameras.
  • Drawn-in-code textures no longer get stuck on the loading spinner forever — if the engine can't bake one, it now says so in the logs instead of hanging.
  • Drawn-in-code terrain and object textures now load reliably after joining, reconnecting, or changing places.
  • Drawn-in-code sprite animations now play normally instead of remaining on their first frame.
  • Drawn-in-code textures now work on objects created or skinned by your scripts at runtime (spawned pickups, the player's facing art, etc.), not just objects placed directly in the scene.
  • Multiplayer damage, knockback, and similar effects now land reliably even while players' other state is changing.
  • Other players can now consistently see projectile effects such as Fireballs in multiplayer.
  • Fixed multiplayer guests seeing the world flash in and out with severe lag after reconnecting or resynchronizing.
  • Fixed multiplayer guests seeing large sections of the world repeatedly disappear and reappear during normal play.
  • Fixed VFX becoming invisible to other players when cast from client-owned gameplay objects.
  • Fixed projectile effects sometimes being invisible to other players in multiplayer while direct effects such as Flamethrower remained visible.
  • Short-lived effects cast by other players now appear reliably in multiplayer, including under latency.
  • Player and controlled-vehicle movement now stays smooth in multiplayer without making your own controls or camera feel delayed.
  • Solo games no longer freeze when player-attached objects such as balloons, equipment, or effects are built as child objects.

technical notes

  • Audio loading reliability: clip fetches carry a 12s timeout that counts toward the 3-attempt budget (a hung response can no longer park a clip in pending forever); /cdn clips probe via the magic-cdn async rails (x-magic-cdn-async) so missing sounds trigger their own generation and 202 still-generating answers retry on Retry-After without burning attempts; pre-session 401s cooldown-park without burning attempts and the cdn-session bootstrap signal re-arms terminally-failed clips; spec/script sound refs warm at explicit priority the moment a spec snapshot applies (the warmer no longer starves explicit warms behind live loads); renderer getStats adds notReadyStarts / failedClipCount / timedOutFetches / stillGeneratingResponses.
  • Audio clips served from Magic CDN (/cdn/*.mp3, /magic/*.mp3) now load through the derived Ogg Opus transport (<name>.mp3.opus — the KTX2 companion pattern for audio) whenever the browser can actually decode it. The two playback paths gate separately: the buffered path (fetch + decodeAudioData) gates on a one-time probe decode of an embedded 137-byte Ogg Opus blob, because Safari's canPlayType answers "maybe" for Ogg Opus while its Web Audio decodeAudioData rejects it in every shipping Safari; the streaming path (HTMLAudioElement) gates on canPlayType('audio/ogg; codecs="opus"') plus a one-shot error fallback that swaps the element back to the mp3 source. Any opus fetch/decode/stream failure marks that clip and it loads as plain mp3 for the rest of its registration (clipHandle/release clear the mark); the buffered downgrade falls through to mp3 inside the same prefetch call, so it never consumes one of the 3 mp3 retry attempts and never marks the clip terminally failed. Specs and Savi keep writing .mp3 — the swap is transport-level. Vibe samples (/cdn/vibe-samples/) are untouched.
  • External audio URLs in specs (.mp3/.wav/.flac) now reroute from the /api/media byte proxy to the Magic CDN public import lane (/cdn/public.<b64>.mp3): the first request imports the file (slower than a straight proxy) and the served bytes are normalized/re-encoded (mp3 identity + lossless master) rather than proxied verbatim; a failed import tombstones briefly instead of falling back to raw proxied bytes. Once imported, these clips get the same .opus companion transport as any CDN mp3. m4a/aac/ogg URLs still proxy verbatim through /api/media (no AAC decoder in the import chain; ogg commonly carries video).
  • The opus companion rides the audio loader's existing Magic CDN probe/timeout machinery (from the audio-loading-reliability work) rather than any bespoke polling: the companion URL goes through the same async-header probe, per-fetch timeout, 202 + Retry-After no-fault retry cadence, and cdn-session gating as the mp3 it shadows — still-generating waits and pre-session 401s park the clip without consuming its 3-attempt retry budget on either transport, while a terminal opus failure (probe 4xx/5xx, failed fetch, empty payload, failed decode, timeout) downgrades that clip to mp3 within the same load.
  • BREAKING (authority model): rewrote multiplayer to be client-authoritative with per-entity single-writer ownership. "multiplayer" (the default) now IS the client-auth model; the server-auth predicted pipeline (compare / resim / rollback) is deleted from the tree. A spec pinned with the legacy "client-auth" value parses and aliases to "multiplayer". Rationale: docs/networking-modes.purpose.md; build contract: docs/client-auth-networking-plan.md.
  • Authority: every entity has exactly one simulator. A client simulates its own ownership envelope (local player + spawn chain) as final; each place's unowned remainder (NPC behaviors, triggers/liquids, tweens, nav, events) is simulated by a host client the server assigns in TomePlaceHosts (replicated, reset-proof, per-place epoch, notified over Control). The server validates, applies, sequences, and fans out — and simulates nothing. There is no resimulation, rollback, or misprediction.
  • Upstream channel RoomClientOpcode.StateDeltas: owners/hosts upload per-tick component deltas. The server authorizes each row against sender ownership — create-chain attribution, per-component clientAuthWrite policy (blocks identity/permission/routing rewrites), per-client flood budgets, and byte caps (1 MiB/message, 64 entries/row, 64 KiB/component value). Host messages stamp {hostedPlaceId, epoch}; stale stamps strip to the envelope gate instead of dropping the message.
  • Host migration keys on events, not grace windows: socket detach, hidden-tab Suspended, place travel, and upload-silence reassign immediately at epoch+1 (adopt-without-firing so migration never re-fires contacts). Empty places pause (single writer holds vacuously) and re-host on the next entrant. Observer clients are fully passive.
  • Server physics removed for client-auth places (no stepping, bodies, or runtime warming); uploaded poses are plain component writes. ObjectAPI physics mutators/queries degrade loudly server-side; proximity rides the spatial index. Hidden-tab owners freeze (no server fallback simulation).
  • Write-through intents keep Savi's natural code correct by construction in both modes: cross-writer effects forward to the owning simulator (rail.interact/emit/enterPlace/triggerPurchase/destroy, rail.voxelEdit through applyVoxelTerrainEdit, withPersistence over SpecMutations with per-mutation provenance + a creator gate on global/authoring mutations).
  • Observability: per-phase server tick budget (preUpdate/input/simulation/postUpdate/replication) in ServerRuntimeTelemetry + F3 overlay + room.server_behind.episode Datadog log; netcode.state_delta.* security-boundary events log through winston; upload counters in the F3 server section. e2e suites drive real server+client runtimes through the real wire codecs (client-auth-e2e.test.ts, client-auth-host-migration-e2e.test.ts). Multiplayer and singleplayer remain byte-identical across the change.
  • Known follow-ups (named in the plan doc): owner-side envelope trigger/liquid lane (hooks currently fire nowhere in client-auth after the server-physics cut) and rapid same-chunk voxel-edit echo flicker (feel-sensitive; needs in-engine validation).
  • Appended-band LOD downgrades are lazy (ledger #639): in the client extended-distance terrain streamer, when a resident chunk's current AND wanted LOD are both in the appended far bands (appendExtendedFarBands), the downgrade re-key is suppressed until wanted lags current by 2 bands — quality only ever ≥ wanted, residency bounded at one band finer than wanted. At 5.0.7 the sole-streamer fix made the extended desktop band (~4k chunks) genuinely resident, so walking caused ~26 full chunk rebuilds/sec of UNCHANGED terrain from band flips (measured: 525 flips per 20s walk at 6 m/s, 75% appended-band-internal); this removes the ~45% of sustained walking churn that was downgrade flips, and rim-spawned far chunks now ride their spawn LOD straight to eviction with zero rebuilds. Upgrades stay immediate, authored bands are byte-identical, the standard profile (server/MP/mobile) is untouched, and eviction semantics are unchanged.
  • Server job pool lane isolation (ledger 643 — the collider-less ground bounce loop): wedged Magic CDN collider generation made every engine/glb-bounds job burn its full 10s deadline (106/106 deadline-exceeded bodies in dump be7fc01e), the flood continuously leased every pool worker, and terrain chunk builds starved behind it (353 stuck-past-deadline-window builds, chunk-rescue firing 2553 times at feetY −690) — including the 5.0.15 remediation's own collider rebuild jobs, which rode the same starved pool and were defeated by it. Job definitions now declare a pool lane: terrain chunk/batch builds are world-critical (dispatched ahead of the pool queue, ordered by priority so a chunk-rescue remediation rebuild at numericPriority 2 jumps even a backed-up terrain lane), glb-bounds is asset (the class is capped at maxWorkers−1 concurrent workers, so asset-bound floods can never lease every worker). The #7194 deadline race and lease watchdog bound each individual job; the lane cap bounds the class.
  • glb-bounds retry backoff: the fixed-cadence resubmit against a still-cooking asset was self-DoS (1,367 errors in one window). The job now probes Magic CDN URLs with the existing x-magic-cdn-async probe first — a cooking asset answers 202 in milliseconds instead of holding the ranged GET ~100s toward a CF 524 — and tags every HTTP-classified failure with httpStatus on the job error payload. The bounds-prefetch feature schedules by failure class, per asset: exponential backoff from 5s doubling to a 5min cap (mirroring the terrain chunk retryBackoffTicks idiom), deferral to the slow cadence after 4 consecutive 202s (cold generation cooks for minutes — fast retries can never outwait it), and 401/403 parks immediately (auth failure ≠ still-cooking; hot retries can never succeed). 202 is terminal for a single job run — in-worker second-scale retries no longer burn the capped asset lane against a cooking asset.
  • Param-driven scripted-material rebuilds on live meshes are async + coalescing (ledger 641 — the Ascent spike storm). Scripts that read ctx.params.x directly bake the value into the node graph as a shader constant, so animating the param fails the uniform fast path (applyScriptedMaterialParams → false) and forces a material rebuild; rebuilding IN PLACE put an uncompiled material on a mesh that was already drawing — a synchronous NodeBuilder codegen + pipeline compile on the renderer worker (66-86ms spikes, 5-10 builds each, ~90% of samples in codegen) or a compile-hide of a visible mesh (the flicker rule). Now the rebuild compiles on a hidden stand-in mesh through the async compiler's new compileReplacement entry (settle-callback contract: fires exactly once — resolved, failed, or dropped) and swaps onto the live mesh only when warm. At most one rebuild is in flight per mesh; param changes that arrive mid-compile coalesce into the entity's material intent, which settle re-reads (latest-wins — no queue growth under per-tick animation). Accepted trade, documented at the schedule site: the live mesh draws the old param value for the few frames the replacement takes. First-time builds, structural (authored) material replacements, and headless (no compiler installed) keep sync semantics; a structural change supersedes any in-flight param rebuild. New teaching diagnostic scripted-material-baked-params (perf-pointer DM rail, runtime log) fires once per ref at the third param-driven rebuild and points at ctx.param() uniforms.
  • Tiling-surface filter exemption now covers pixel-texture-* basenames. #7208 exempted the texture-* tiling-surface class from pixel-filter inference so tiled grounds keep linear+mips — but the wave-7 assets that motivated the fix are scopeless top-level CDN textures wearing the vibe prefix (/cdn/pixel-texture-dark-cave-rock-wall.png, /cdn/pixel-texture-soft-blue-sky-day-gradient.png), whose basenames start with pixel-texture-, not texture- — they still inferred filter: "pixel" and still swam. isTilingSurfaceTextureId now also matches the pixel- vibe prefix; other kind prefixes stay positional (sprite-texture-artist remains a sprite), and authored sprite.filter still wins both ways.
  • Projection-reset re-lead gets the null-RTT fallback (ledger #637/#631 — the third clock-rebase site, the one #7207 missed): every resetProjection snapshot re-leads the client clock via calculateJoinLeadTicks(rate, getRttMs()), and on 5.0.15 that lead was still RTT-blind when no round trip was measured. A wedged input lane is precisely the lane whose ack stream is starved (RTT null — no sample ever minted, or every sample aged out of the rolling window during the wedge), and the #7189 ingress-wedge self-heal cures a wedge by sending exactly this reset — so every heal re-landed the clock inside the server's consumption horizon, every frame arrived too_late again (no rescue by contract; tick-exact actions are never re-timed), and the heal re-poisoned the wedge it was sent to cure (Vacuo on 5.0.15: arrived=2785 tooLate=2785 tooFar=0, appliedPresent=0 for entire sessions, ingress_wedge_unhealed twice). Post-join resets now size an unmeasured-RTT re-lead for JOIN_LEAD_MAX_RTT_MS, same rule as the #7207 hard-adopt and suspend-resume rebases: erring too_far is recoverable (buffer empty-rebase + throttle grind). The FIRST reset — the join handshake the clock derives from — keeps the optimistic cold lead (the server's input buffer seeds its consumption floor from the client's first frame, and a worst-case lead would hand every join ~1s of surplus input latency), pinned by join-fast-forward tests. Audited every remaining calculateJoinLeadTicks call site: the behind-resync and clock-runaway thresholds stay null-led on purpose (detectors must stay tight; the cuts they trigger carry the fallback themselves), and the lead-recovery slew target is exempt with its rationale documented in place (forward-only, measured against received ticks in the same frame of reference the join led from, and the too_late equilibrium is invisible to its detector anyway).
  • RTT samples now only ride inputKind: "present" acks — the conservative-lead fallback (#7207/#7218) was structurally unreachable for wedged clients (ledger #647). The projection-reset re-lead falls back to JOIN_LEAD_MAX_RTT_MS only when getRttMs() is null, but RTT was never null for a wedged client: MultiplayerDebugTransport.recordAcks (engine/runtime/client/multiplayer-debug.ts) minted an RTT sample for ANY ack whose tick matched a sent input frame, and the server acks every client every tick — even when it applied nothing (inputKind: "absent"). With sentInputTimeByTick retained 60s and the RTT window only 15s, a wedged client (every frame too_late → applied nothing → absent) still produced matchable acks, so the rolling window stayed populated with small measured RTTs forever and netcode.ts:265 always took the measured value. Every #7189 ingress-wedge heal re-led the clock RTT-blind-equivalent, landed below the server's consumption floor, went too_late again, and re-poisoned the wedge every 5min forever (Vacuo f3252536 on 5.0.15: 16 rehandshakes, appliedPresent bursts then decays to 0, tooFar≈0 — proving the conservative fallback was never applied). The fix gates the RTT sample on inputKind === "present" (the only ack that is a genuine round trip of an APPLIED frame); absent/decayed acks route to noteAckWithoutSample (advance lastAckTick, mint no sample, drop the matched sent-tick). A sustained wedge now starves the present-ack stream, the rolling window prunes to null inside DEFAULT_WINDOW_MS, and the existing ?? JOIN_LEAD_MAX_RTT_MS fallback finally engages — the re-led clock lands too_far (recoverable: buffer empty-rebase + throttle grind) instead of too_late, exactly the premise the netcode.ts:259 comment already assumed. Healthy clients get a present ack every tick, so their RTT stays measured and join-lead sizing is unchanged. Regression-pinned in multiplayer-debug.test.ts (wedge → RTT prunes null; healthy → RTT stays measured).
  • Scripted materials: params are uniforms by construction (ledger 641 — one compile per script). ctx.params reads of present, truthy numbers/booleans return TSL uniform nodes inside material(ctx) (scripted-material.ts createAutoParamsProxy); after each build a classification pass verifies every minted node landed in the returned node graph and re-runs the builder with raw values for any key the script inspected in untrappable ways (=== 2, typeof, plain-property assignment like m.ior = ctx.params.ior) — so a registered param uniform is, by construction, one the shader actually reads, and patching it is always sound. Plain-JS consumption (arithmetic, relational compares, string coercion) transparently yields the raw value via coercion traps on the node, keeping legacy math byte-exact and routing those keys to the rebuild path. Falsy reads stay raw so x || d / if (x) semantics never change (falsy patches rebuild). New ctx.constants is the explicit raw escape hatch for structural params. applyScriptedMaterialParams now also: treats params the build never read as free no-ops (kills the permanent rebuild storm from stale spec keys), and rebuilds on string param changes (previously a silent no-op forever). This removes the Ascent-class hitch storm: animating a ctx.params.*-driven material was 5–10 NodeBuilder rebuilds (~70ms) every patch; it is now one uniform buffer write. Param VALUES never key the compile cache or the structural material signature.
  • Scripted-material shaders now precompile ahead of first appearance. A scripted material on a standalone primitive renders as a plain THREE.Mesh, so three keys its pipeline by VALUE (material config + geometry attribute layout + the scene's lights/clipping/context) — and ledger 641 made scripted params uniforms, so param values never key the cache. That makes the (ref × geometry) pipeline warmable from the spec before any object wears it. New flow: the sim derives a MaterialWarmHints component (replicate "never", forwardToRenderer "always") of every distinct (scripted ref × standard-primitive geometry) pair in the spec (tome/material-warm-hints.ts, written from applySpec beside the script libraries); the renderer's new scripted-material-warmer.ts builds each pair's warm mesh and compileAsyncs it against the LIVE scene + camera during idle frames only — gated on the shared frame-idle verdict (post-boot-drain AND idle-now), one warm per frame, never competing with a real compile or load. Warm meshes are retained (never added to the scene, never disposed while warm) so three's per-RenderObject usedTimes refcount keeps the shared nodeBuilderState/pipeline cache entry alive until a real consumer arrives; when the real primitive appears its compile is a cache hit instead of the ~90ms TSL→WGSL codegen + pipeline stall (ledger #152). Edits re-warm (the material-scripts handler's changed-refs drive the warmer's invalidate); vanished targets drop and dispose. Pure best-effort, like the asset preload sweep: a stale target costs only idle work and a missing one falls to the existing compile-hidden async path. Instanced/skinned/morph consumers are out of scope — three keys those pipelines per-object (RenderObject adds object.uuid), so a separate warm mesh can't share them; they keep the async path.
  • Chronic server-behind banner (client presentation of an existing condition — zero server changes): a new client-side cadence monitor (engine/runtime/client/server-behind-cadence.ts) watches the rate the authoritative tick advances against wall clock — every StateDelta is stamped with the server's current sim tick, so the ratio is the server's sim-clock rate, independent of delivery cadence (sparse AOI-quiet deltas still carry full tick advance) and blind to client-side lag. When cadence holds at ≤0.7× across a trailing 10s window on a provably-live socket (wire activity within 2.5s — heartbeats count; pure silence stays the reconnect/watchdog story), the kernel iframe shows a top-center "Server running behind…" pill (same visual family as kiln's "Reconnecting…" connection pill) and the host emits one [worker-browser-host] warn per episode edge through the iframe→parent console-forwarding lane to DD, carrying roomId + cadence ratio + observed/expected ticks (appId/variantId/roomMode ride the forwarding payload). Recovery requires the windowed ratio back at ≥0.9 (hysteresis — no flicker), and the window resets on every discontinuity: connection-phase edges, projection resets (reconnect resume, place travel, ingress-wedge heal), visibility flips, and deep tick regressions (the playout clock's rebase classification). Detection is fed by the netcode ingress system via the new NetcodeClientOptions.serverBehindCadence seam, Ready-phase multiplayer only.
  • Restored the server's ECS retention prune lost in the codex-netcode merge (f142d013b — ledger 645): the ECS contract makes the host responsible for world.prune(beforeTick) on a ~30s window, the client runtime prunes every tick, and the pre-merge server host pruned once per second — but createServerRuntime had zero prune call sites, so on every long-lived room the change log grew append-only (an awake body appends a row EVERY tick; ~108k tick segments/hour), preRemoveRef[]/eventValue[] pinned object refs for every remove/event of the session, entity indices never recycled, and the despawn ring grew forever: heap growth → GC pauses → seconds-class "slow system" spikes attributed to arbitrary systems on long rooms. The restored call mirrors the old host exactly (30s window, once-per-second cadence — compaction is O(retained rows)) and runs at the end of executeOneTick, after the replication phase has drained. Watermark safety verified in code: connections lagging beyond the window never need pruned rows — every lag class (grace-window reattach, suspend-resume, send failure, stale bucket, ingress-wedge re-handshake) funnels into NeedsReset → sendResetSnapshot, which rebuilds from live state (blob cache + dictionary snapshot), and an idle Streaming connection's gap contains no AOI-visible rows by construction (a visible row forces a send every egress tick while retained; drainDelta additionally clamps to retained rows and flags needsFullsync, pinned at the ECS level). No giant-first-prune hazard: engine version flips mint new containers, so no live room carries an unpruned backlog into this code, and with the prune active the backlog never exceeds window + 1s. Gauges added so dumps prove/disprove accumulation in one read: world.debugMemoryStats() (entities, entity capacity, change-log rows/ticks, oldest retained tick, despawn ring) on the server debug telemetry as runtime.ecs, and per-place physics occupancy (handles/bodies/colliders, rapier + mantle) as runtime.physics.places, both rendered in the multiplayer debug dump. Regression pins: a churning server runtime keeps history bounded by window+cadence and recycles entity indices (fails on the unpruned parent), and a real-pipeline reattach across a >30s pruned gap takes the reset-snapshot full sync, converges exactly, and books zero system errors.
  • Horde skinned-batch programs are nameable and the palette compute warms at mint (ledger #638, compile census rows 11/12): the batch render material and palette compute kernel used unnamed storage() nodes, so WGSL named them NodeBuffer_<node.id> and every batch mint — every wave of an identical crowd, plus every capacity regrow — emitted structurally novel shader text that could never hit the program cache (Itero breadcrumbs: 933-1821ms hitches, ~10.7s frozen per wave-spawn window). The storage nodes now carry shape-stable names (hordeBakedFrames/hordePalette/hordeAnimParams/hordeInstanceMatrices/hordeFlash — the fxGpuRenderInstances law from #263), so byte-identical crowds emit byte-identical WGSL wave after wave and the program/pipeline caches hit. The palette compute kernel additionally sync-compiled inside the frame at the batch's first visible renderer.compute; a minted batch that isn't drawing yet now warms its kernel off-frame via a zero-work dispatch (packedCount 0, the fx-gpu spawnPassWarmed idiom), with the in-frame sync dispatch kept as the fallback when the batch draws before the warm fires. WGSL-identity and warm pins ride skinned-batch-wgsl-identity.test.ts.
  • Changed behavior-submitted storage authorization to scope player-owned keys by stable authenticated user id (user/<userId>/...) instead of mutable player entity, client, or room/session identifiers.
  • Forwarded built-in storage:* jobs in client-auth rooms continue to execute on the room server while preserving sender-scoped user authorization; cross-player aggregation (storage:list/storage:query) remains server-context (cron / lifecycle / custom jobs).
  • Updated Savi's storage, room, lifecycle, leaderboard, and API reference material to teach objectApi.userId as the durable save-key identity and the per-player-write → cron-rollup leaderboard convention.
  • Added regression coverage that forwarded storage:set and server-context storage:get/list share the same user/ namespace.
  • WGSL sampler declarations now match every call-site (ledger #640, fork patch three-0.184.19-spawn.4.tgz). The fork's declaration pass gates the ${prop}_sampler binding on filterability (isUnfilterable in getUniformFromNode/getUniforms, with sampler_comparison additionally requiring isSampleCompare), but the generateTextureGrad/generateTextureBias/generateTextureCompare emitters referenced ${prop}_sampler unconditionally — one unfilterable texture (nearest+nearest, uint/sint data, unfilterable float, multisampled) whose graph carried .grad()/.bias()/.compare() produced unresolved value 'nodeUniformN_sampler', the device rejected the WHOLE shader module, and the fork silently skipped that draw forever (invisible terrain/water/shadow catchers). The emitters now carry the same gates as the declaration pass and fall back to an explicit-level load (plus a step() compare for the comparison case) — worst case a wrong-LOD/unfiltered sample, never an invalid module. The kernel's own raw-WGSL CompareLevelTextureNode (shadow atlas) gets the matching guard and defers to the builder's guarded compare path when the comparison sampler binding doesn't exist. Pinned by ledger-640-sampler-decl.test.ts (the three repro shapes + filterable controls + both CompareLevelTextureNode sides), and a blanket undeclared-sampler scanner (wgslUndeclaredSamplerRefs) now runs on every shader the WGSL test harness builds. When a pipeline failure does quote an unresolved sampler (a fork regression or a new emitter), the renderer-pipeline-failed diagnostic now names the failing material's sampler-withheld texture (sampler-decl-culprit.ts).
  • Client-auth spawnFx calls now bypass write-through intent spawn deferral: replicated visual effects remain immediate and upload normally even when the behavior source has an earlier cross-writer intent awaiting a verdict, so shots reach observing clients without weakening deferral for durable api.spawn state.
  • Client room ingress now bounds unconsumed StateDelta history, lets reset snapshots supersede queued deltas, discards state from closed sockets, and requests a current projection instead of replaying stale server history after a stall.
  • Sim-to-render recovery now replaces presentation backlog older than one second with an in-stream dictionary/entity reset and current-world snapshot, preventing historical movement and effects from playing forward after the renderer resumes.
  • Default new-game humanoid locomotion now derives body yaw from the renderer-authoritative aimYawSin/aimYawCos camera basis instead of horizontal velocity, so strafing and backpedaling preserve look direction.
  • The First-Person Camera skill now teaches player-side look-facing, camera-only pitch, required derived aim axes, and removal of competing velocity-facing or animated3DCharacter.faceMovement rotation.
  • Scripted textures (tex-*.js drawn-in-code art) no longer hang forever when the bake worker fails to start. The bake transport's watchdog was armed only after a request reached a ready worker, so a worker that spawned but never posted {kind:"ready"} left every request parked in the pre-ready queue with no timeout, fault, or diagnostic. The watchdog is now armed at enqueue to cover startup and re-armed when the request reaches a ready worker so a slow cold start does not consume the bake window. Expiry tears down the worker and reports a budget fault instead of leaving a permanent loading placeholder. Synchronous worker construction/post failures now clear their pending watchdog, and late events from a replaced worker are ignored so they cannot tear down its healthy replacement. (This diagnoses the nested-worker startup failure; the off-thread path itself is unchanged.)
  • Rebuild the client-local TextureScripts source library when replicated TomeSpec, DrawSprite, or DrawMaterial state arrives. Projection resets recreate the replicated tome/spec entity and previously discarded its non-replicated texture library without reapplying an unchanged spec revision, leaving terrain tilesets and server-spawned scripted textures permanently missing in the renderer.
  • Relay baked ctx.atlas metadata from the renderer worker to the client runtime's existing sprite animation clock. Scripted sprite atlases previously rendered successfully but stayed on frame zero because their animation metadata existed only in the renderer worker.
  • Scripted textures (scripts/tex-*.js / art-*.js drawn-in-code art) assigned at runtime now reach the renderer instead of failing "not found". The TextureScripts library was derived only from the static spec walk (sprite.texture / material.texture / terrain.tileset on spec objects) plus the fx-sink ensure hook, so a scripts/ texture set by a behavior was never collected. writeDrawSprite and deriveMaterialDirect now ensure runtime sprite refs and every texture-bearing material override (map, normal/PBR maps, alpha/matcap, and water maps), mirroring runtime scripted-material registration. The hook no-ops on non-script refs and unchanged source hashes.
  • Fixed client-auth query and hook snapshots eagerly guarding every top-level state field, which caused cross-player damage, knockback, and other forwarded effects to be rejected when unrelated target state changed in flight.
  • Fixed attached spawnFx() effects escaping before a taint-deferred projectile parent was promoted, which caused the server to reject the orphaned effect and made projectile visuals invisible to other players.
  • Latched stale render-stream recovery until its reset barrier is consumed, preventing backward client tick rebases from queuing a full reset and world snapshot every frame.
  • Aged sim-to-render backlog with writer-local presentation ticks instead of authoritative ticks embedded in remote ECS deltas, preventing healthy non-host queues from being misclassified as infinitely stale and repeatedly reset.
  • Track unresolved client-auth spawn previews explicitly so spawnFx() only waits for an intent verdict when its source or parent is an actual deferred preview, rather than any client-realm entity.
  • Client-authoritative intent effects are now keyed only to the behavior invocation's causal refs. Stored timer, job, and event callbacks carry their registrar's refs, while unrelated later invocations no longer inherit an earlier cross-writer operation's deferral. Network latency therefore cannot keep unrelated projectiles and attached FX local-only without allowing a dropped premise to mint delayed side effects.
  • Preserved ECS change-log append order in the renderer drain instead of sorting mixed server and client tick domains, so client-derived visual components cannot arrive before their remote entity spawn and be dropped.
  • Added a client-only presentation policy that gives remote players and their active control targets one additional buffered transform tick without delaying the camera, local player, projectiles, or world entities.
  • Retimed renderer deltas onto the client presentation clock so server-stamped ECS changes cannot make local player or camera snapshots appear stale.
  • Fixed remote player freeze-and-jump motion when a client-auth transform upload misses one otherwise-current server batch.
  • Singleplayer remote-player cleanup now preserves the local player's slash-qualified child entities. The per-tick sweep previously treated untagged children such as player/<local-id>/balloon/body as stale remote players, repeatedly despawning game-authored attachments while their behavior recreated them and eventually overwhelming the render worker.

Engine v5.0.15

Released June 12, 2026

  • Multiple moving shadow-casting lights now all keep their shadows — previously only one moving light at a time could.
  • Fixed long lag-behind sessions endlessly rubber-banding — the game now does one clean catch-up resync instead.
  • Fixed buttons and controls permanently dying after a slow game load — presses no longer "work for a split second, then snap back". If your game's UI broke when you updated past 5.0.7, this is that fix.
  • Lights are now easy to select in god mode — the click target is the light itself, not its entire glow radius.
  • Fixed a rare condition where the game stopped responding to your controls after a server restart until you reloaded.
  • Fixed a bug where a world's ground and collision could fail to load (players falling or floating at spawn) when an asset server was slow — terrain now builds reliably even when assets are having a bad day.
  • Worlds with freshly generated models load more reliably — slow first-time asset processing no longer makes physics and models fail to show up until a retry.
  • Ground textures in 2D games no longer shimmer while the camera moves.
  • If your world ever stops drawing, the engine now notices within seconds and tells Savi exactly what happened — that it's a graphics-driver problem a browser restart fixes, not a bug in your game — instead of leaving a silent black screen.

technical notes

  • ShadowAtlasScheduler's dirty render queue is now ordered oldest-rendered-first (the stale queue's existing age idiom; never-rendered faces lead, score breaks age ties) instead of score-first. A moving shadow-casting light re-dirties every face every frame, so under a per-frame face budget smaller than the standing dirty set the score-first order replayed the strongest light's first budget faces forever — starving every other dirty light AND the strongest light's own remaining faces whenever its face count exceeded the budget (high tier: rendersPerFrame 4 < a point light's 6 faces, so no orbiting point light could ever complete acquisition and ready/fade stayed pinned at 0 — the jure 1-of-3 field repro: three shadowed point lights, two on orbit/bob behaviors, only the static one ever cast). Age ordering round-robins the budget across all dirty faces, bounding per-face staleness at ceil(dirtyFaces / budget) frames: every moving light keeps a slightly-lagged shadow instead of one light keeping a live one. The budget cap itself is unchanged — fairness redistributes who gets the renders, not how many happen.
  • Client behind-server resync (ledger 631, dump 7797adb0 "Ark Caves"): a client whose local clock fell moderately behind the server's tick (under the suspend-resume cut's instant threshold, beyond what the lead-recovery slew was healing — Ark Caves sat ~40 ticks behind for hours) used to fire a mismatch correction on every compare (20,554/20,554 resims, ~81ms/s replay burn) because its input frames reached the server after their ticks were already simulated. Behind is now a resync condition, not a mismatch condition: (a) prediction compares are suppressed while the client is strictly behind the newest authoritative tick (a properly leading client keeps full drift detection — the gate is strictly client-behind-server), and (b) a fall-behind that sustains above 2× the join-style lead for 30 consecutive ticks with fresh authoritative arrivals triggers ONE full resync through the existing suspend-resume cut (authoritative-snapshot adopt + clock re-lead + input baseline reset + prediction recapture — never fabricated state, tick-exact input apply untouched). Flap-guarded: one resync per 15s; a re-trip inside the window is held and escalated loudly once.
  • Clock rebases size their lead for the worst tolerated RTT when none is measured (ledger 637, the Vacuo 5.0.8 input-death regression). RTT samples ride input-frame acks, so a boot-time stall that blocks the server before any frame is acked reaches the hard-adopt rebase with RTT=null; the old RTT-blind lead (~8 ticks, no transit term) parked the client clock inside the server's consumption horizon for any real RTT above it. Every subsequent frame arrived too_late — hard-dropped, actions acked-as-dropped, the client un-applying each predicted action (buttons "work a split second, then snap back") — and since no frame was ever accepted, no ack ever minted an RTT sample, so the next adopt re-rebased blind: permanent, self-sealing input death, invisible to the behind-server detector (the clock stays nominally ahead of the newest received snapshot while behind the horizon). Both rebase sites (hard-adopt and suspend-resume cut) now fall back to JOIN_LEAD_MAX_RTT_MS when RTT is unmeasured: overshooting strands the clock slightly too_far — the side with the buffer's empty-rebase rescue and the input-throttle grind — while undershooting has no rescue by contract. Field-confirmed on master before the fix (arrived=2262 tooLate=2261 appliedPresent=0, self-heal re-poisoning within 5 minutes).
  • God-mode lights now pick by a compact bulb-sized handle instead of effectively by their photometric range. A light has no raycastable geometry of its own — its pick surface is the editor visual, and while selected that visual spawned two range-radius torus rings (range-flat/range-vertical, radius = min(distance, 16)) whose hits resolved up TomeParent to the light: every click inside the glow radius could land on a ring and re-select the light (the jure field report: "very difficult to work with", selection low-percentage, a huge mostly-empty sphere competing for picks). Three changes: (1) editor-visual GUIDE geometry (range rings, beam edges, emission wireframes — anything nested under …/__god_mode_visual/) is now non-pickable in both god-mode pick consumers (hover's pickable() and click-to-select's hit filters, the same idiom as the spline connector and the bed-footprint ribbon) — the visual ROOT (the marker) stays pickable, it is the light's click target; (2) the light marker is bulb-sized (octahedron radius 0.25 ≈ 0.5-unit click target, fixed, never scaled by distance) instead of the 0.11/0.14 gem all markers shared; (3) the selection-outline subtree walk skips guide entities, so the silhouette cue traces the light + its marker — never the range-radius rings (the giant artifact-y outline in the screenshots). The range visualization itself is unchanged: rings still appear only while selected, at the real falloff radius, as hairline wireframes. Regression gate: editor-visual test pins every pickable entity under a light to ≤ 0.6 u bounds for distance 5/18/100.
  • Input-ingress health observability (ledger 631, dump 7797adb0 "Ark Caves"): the server's per-client input acceptance counters (framesReceived, framesDroppedInvalid, the per-client truth row, the buffer's too_late/too_far tallies) were stats-only — visible in a debug dump, never logged — so a wholesale input-acceptance failure (server booking absent for every tick of a connected, egress-receiving client for hours) ran past two real diagnoses. The netcode egress system now samples a per-client ingress-health monitor (engine/runtime/server/input-ingress-health.ts) every tick: (a) one tome.input.ingress_health info line per client per ~30s carrying the window's arrival/apply/drop deltas and absent-tick ratio, and (b) a loud tome.input.ingress_wedged warn when ≥90% of a ~10s window's ticks book absent while the same socket delivered egress — the half-open wedge signature (ingress dead, egress alive). Suspended and grace-detached connections are never sampled; clients that never enqueued an input frame (singleplayer authorities, spectators) emit nothing. Observe-only: no engine behavior change.
  • Input-ingress self-heal (ledger 631, dump 7797adb0 "Ark Caves"): when a client's input ingress wedges half-open — every tick books an absent ack while the same socket keeps delivering egress (the ingress-health detector's condition) — the netcode server now forces ONE input-session re-handshake instead of letting the session stay input-dead until the room dies. The cure reuses existing handshake shapes only (no new wire message, no invented state): resetClient tears down the per-client input session (the tick buffer whose consumption floor gets poisoned when a fresh buffer seeds its baseline from a reconnecting client's stale-clock frames, plus pending acks/flow control), the Loaded welcome re-grants the session's live entities (the hello that arms client input sampling), and a dedicated reset snapshot (resetProjection) triggers the client's existing re-sync (handleProjectionReset: clock re-lead + input baseline reset). Flap-guarded: one re-handshake per client per 5 minutes; a second wedge inside the window logs a loud tome.input.ingress_wedge_unhealed error once and leaves the session for a human. Healthy clients can never trigger it (the detector requires a ≥90%-absent window with egress delivered, and suspended/grace-detached connections are never sampled).
  • Fixed the fleet chunk-build wedge (2026-06-12, ledger 634): a job whose fn never resolved (e.g. a glb-bounds fetch against a 524ing Magic CDN) leased its server job-pool worker forever — deadlines were only checked after the fn returned, so nothing errored and every job submitted afterwards (all terrain chunk builds) starved silently until the queue-side stuck window failed them en masse.
  • Job deadlines are now a live race in the worker harness: a job that outlives its deadlineMs is aborted (its env.signal fires) and fails with deadline_exceeded immediately, freeing the worker. deadlineBehavior: "warn" keeps the legacy accept-late-results semantics.
  • The server pool gained a main-thread lease watchdog (60s cap): a worker whose job outlives the cap — sync-spinning or abort-deaf jobs the in-worker race can't reach — is terminated, the job fails loudly with lease_expired, and a replacement spawns.
  • A worker that exits cleanly while running a job no longer silently strands that job; crash requeues are capped (2) before the job fails with worker_crash instead of crash-looping the pool. Async worker-spawn failures now drain the queue with pool_unavailable errors instead of stranding jobs.
  • glb-bounds fetch attempts are hard-bounded at 4s (transient, so in-worker retries stay reachable) instead of holding a connection ~100s until Cloudflare 524s it.
  • Observability: terrain chunk-build failure / chunk-rescue / collider-watchdog logs now route through the identity-stamped logger (appId/roomId/engineVersion instead of appid:unknown), and job workers inherit the bound log identity at spawn.
  • Fixed the Magic CDN async probe skipping every server-side asset load (ledger 636, cold-asset 524s): isMagicCdnUrl only matched /cdn/ paths, but the server-side AssetService rewrites /cdn/ → /magic/ (convertCdnPathForServer) before the loaders run — so container-side loads (model warming, collider hull fetches) never sent x-magic-cdn-async and held the connection open through a 3–6 minute cold cook, dying as proxy 524s. /magic/ paths now take the probe path too: cold assets answer 202 + Retry-After and the load retries on cooldown instead of riding a doomed connection.
  • 2D pixel-art filter grammar: tiling surface textures (texture-* filename grammar) now keep linear+mips sampling instead of inheriting filter: "pixel" from a -pixel- moodboard scope or pixel- name. The pixel inference sets min AND mag to NearestFilter, leaving the KTX2 mip chain unused — a ground tiled 48×48 sits several mip levels down, so every fractional camera offset re-picked texels and the floor scintillated with camera motion (4 sightings in wave-7 2D testing). The fix extends the existing plate exception (backdrop-*/strip-*/floor-*) to the tiling-surface class via isTilingSurfaceTextureId, mirrored in both inference copies (renderer-asset-service + worker asset service). Sprites, tilesets, and character art stay nearest/crisp; authored sprite.filter still wins both ways. The general alternative (min=NearestMipmapLinear for the whole pixel class) was considered and deferred — mip averaging thins alpha on cutout sprites.
  • Sim-tick timing ring in the debug dump (ledger 631, Tucker): per-tick sim durations for the last ~45 ticks, client and server, so a dump answers "is the sim itself falling behind the tick budget" directly and crosses with Datadog app metrics to find slow systems. Client: the runtime books each MAIN simulation phase's ms into a fixed 45-slot ring (O(1) per tick, zero per-tick allocation; resim replay steps excluded — burst work already accounted by ResimulationStats), shipped on every perf rollup as payload.simTiming and retained by the kiln parent for the dump capture. Server: each executed tick's ms rides ServerRuntimeTelemetrySnapshot.tickMsRing and the room's /admin/input-stats dump capture. The dump summary renders both as compact min/p50/p95/max lines plus the raw rings. Observe-only: no engine behavior change.
  • World-draw liveness sentinel (ledger 633, dump d96c25f3 "Spawnblock"): detects the silent GPU-process-reset class where a browser reset/eviction zeroes the vertex-pulled voxel bucket arenas / compute-cull storage WITHOUT a device-lost event — frames keep presenting at 2.5ms, every error rail stays empty, the world draws nothing. CPU info stats are structurally blind here (an indirect bucket counts 6 indices × 1 instance in info.triangles whether it draws a million quads or zero), so the trip predicate is CPU expectation vs GPU truth: the CPU-mirror chunk tables (bounds sphere + transform + live quad count — the upload SOURCE for the GPU buffers) walked against the camera frustum say how many quads SHOULD draw, while a ~20-byte async readback of the cull pass's GPU-written indirect instanceCount says how many DO. Expected ≥ 64 while drawn = 0, sustained 8s across consecutive 2.5s probes, fires ONE diagnostic (renderer-world-draw-stalled, allowlisted to Savi's getLogs + DM) carrying a trip-time forensic payload (bucket mesh flags, arena residency, GPU chunk-visible readback, last cull dispatch age, chunks installed, texture bytes). One-shot per session; a live probe re-arms it. Per-frame cost is O(1) — the expectation walk and readback ride the probe cadence, forensics run once at trip time.
  • Voxel-bucket containment weight (#7171 lineage, in the post-#7176 alarm path): when handlePipelineResourceLimitExceeded hides an object whose material is a terrain/voxel-bucket/* pipeline, the diagnostic now says what actually happened — hiding that one mesh hides the WHOLE voxel world (device-limit fallback, world-level impact, not a game-script bug) — instead of the generic "hid the object" line.
  • console.warn forwarding (iframe→parent, ledger 268 lane): the kernel client now forwards console.warn lines that start with the engine's own prefix markers (the [renderer]/[worker-browser-host] family) as source: "console.warn", on their own 10/min budget so a warn storm can never starve error forwarding; creator/third-party warns never leave the page. The renderer host re-emits warn-class worker diagnostics (renderer-build-failed, the pipeline-guard rejections) on the page console as [renderer] warns so they ride the new lane, and the world-draw stall re-emits as console.error so the world-down class rides the existing error forwarding. kiln's parent listener accepts the new source and logs it to Datadog at WARN status (apps/kiln/lib/kernel-iframe-errors.ts).

Engine v5.0.14

Released June 12, 2026

  • Colored and projector light shadows now work for as many lights as your scene needs — they previously stopped working past one or two on many graphics cards.
  • Fixed mouse-look games erroring on Firefox when capturing the mouse.
  • Worlds with lots of lights and detailed terrain now pick a fitting quality automatically on limited graphics cards instead of failing to draw.
  • A texture script that takes too long to draw can no longer freeze your game — the engine stops the draw within its budget, keeps the texture's last good image, and tells Savi exactly which script ran long and for how long.

technical notes

  • Special-spot shadows (IES/projector/custom-color spots) folded into the local-light ShadowAtlas: the atlas grew a lazy transmitted-color layer (transmittedRenderTarget — atlas-shaped, shares the depth texture, so a special-spot cell renders depth + tint in ONE scheduled pass at the ordinary per-face budget cost), and each special spot's stock per-light ShadowNode is replaced by a custom light.shadow.shadowNode (SpecialSpotAtlasShadowNode, the SunCascadeShadow/directional-bank seam) that samples the shared atlas depth + slot records + transmitted layer. N colored-shadow lights now cost a flat +1 sampled texture and +1 sampler (the per-light era cost 2N of each — on the universal 16-sampler grant that capped colored shadows at 1-2 lights, the jure field report). Scheduling is the atlas's existing top-K/budget/caching, with special spots competing as ordinary spot candidates via the data node's extra-lights channel.
  • The #7176 special-spot admission machinery is deleted (spot-shadow-admission.ts: admitSpecialSpotShadows, releaseSpotShadowRequest, the demotion diagnostics; maxAdmittedSpecialSpotShadows, SPECIAL_SPOT_SHADOW_SAMPLED_TEXTURES in quality.ts) — there are no per-light shadow pairs left to admit. The demand model is composition-free: the lit stack counts the atlas as a trio (depth + slots + transmitted; tiers land at 14, low 11), terrainPipelineSampledTextureDemand/selectTerrainShadingVariant lose their special-spot parameter, and the pipeline-rejection alarm handler is unchanged. Atlas-less sessions (low tier, single-sun device floor) give special spots a shared neutral shadow node — lit, castless, structurally unable to mint per-light shadow targets, matching the sun-only semantic every other local light already has there.
  • The atlas tap TSL (slot decode, gutter window, rotated-grid compare kernel) is extracted to shadow-atlas/atlas-tap.ts and shared verbatim by the froxel loop and the special-spot node; the transmitted tap samples at explicit mip 0 (uniformity-safe). WGSL receipts: 5 shadow-casting custom-color spots compile to byte-equal binding/sampler counts as 1 (4 textures + 2 samplers for the whole lighting stack in the test material), inside a 16/16 grant, dominance-clean.
  • Firefox pointer-lock fix (ledger 629, prod DD receipts: TypeError: can't access property "catch" on every acquire): Chrome/WebKit return a Promise from requestPointerLock, Firefox returns undefined — the pointer-lock manager chained .catch directly on the return, so on Firefox every acquire threw in the edge-triggered reconcile. The lock request itself was issued before the throw, but the rejection handler / gesture-gate arming / prompt update never ran and the TypeError propagated up the reconcile path. The return is now normalized through Promise.resolve before chaining (return-normalization only — no gate/reconcile semantics change), and Firefox's void-return signature joins the documented browser realities in the manager header. Pinned by a Firefox-shaped test (void-returning request mock: acquire never throws, lockchange still settles, gate behavior intact).
  • Capability admission replaces the texture-budget ladder (Jacob's ruling: never request more resources than the adapter supports — rungless; rungs are for performance, not device capability). The sampled-texture demand model is complete: shadow-casting IES/projector/custom-color spots bind a per-light depth + transmitted pair in every lit shader (previously uncounted — the gray-screen mechanism on 16-grant devices, partner report "Higher": counted terrain stack 11 + 3 uncounted pairs = 17). maxAdmittedSpecialSpotShadows caps those pairs per scene composition at the light-membership refresh (spot-shadow-admission.ts — deterministic creation-order demotion with a one-time diagnostic, restored when budget frees), and selectTerrainShadingVariant picks the terrain shading variant (full / simplified-lit / unlit) statically from the grant at terrain-resources creation. No pipeline whose stage demand exceeds the grant is ever built.
  • The reactive walk machinery is gone: degradeTerrainTextureBudget, the scene-level textureBudgetRung escalation, and the @budget material-name re-arm regex are deleted. The simplified-lit and unlit terrain compile paths survive as static selection targets. A pipeline-resource rejection now hides the object and reports an engine resource-accounting bug — it is an alarm, never an input.
  • The texture-budget suite pins the demand model against compiled WGSL (full variant 5 fragment bindings, simplified-lit/unlit 3), proves demand ≤ grant by construction for worst-case compositions on every tier and grant down to the WebGPU spec minimum, and pins the deterministic composition-change demotion order.
  • Client-wedge class fix (ledger 628 forensics, game "Press Quest", app 8f40c804: a scripted texture re-baked client-side for ~446s and silently froze the player): the texture-script bake budget (TEXTURE_SCRIPT_BAKE_BUDGET_MS, 50ms wall clock) is now enforced MID-DRAW instead of only after the draw returns. Every script-facing 2D context is wrapped in a budget proxy (clock sampled every 32 ops, methods bind-cached), ctx.random() and ctx.canvas() sample the clock, and ctx.atlas() checks at every cell boundary — a runaway draw aborts within the budget with the existing budget-fault park instead of running for minutes. This closes the unbounded synchronous hole in the inline bake transport (hosts without nested Worker, where the bake runs on the renderer host thread); on the worker transport it also retires most 5s-watchdog kills. The residual case (a loop that never touches the ctx surface) remains covered by the worker watchdog.
  • A faulted RE-bake now keeps the previous texture: the asset service's script-edit sweep restores the old cache entry when the new bake parks (compile/runtime/budget), so consumers keep the last good texels instead of dropping to a placeholder and the retired-texture grace sweep can no longer dispose art that is still on screen. A later fixing edit swaps and retires normally.
  • Budget diagnostics now carry the elapsed time (elapsedMs in the report data and in the message) and state what the engine did ("The engine kept the texture's last good image (or its loading placeholder if it never baked)"), riding the existing texture-script-budget rail to getLogs + DM so Savi can see a client-side bake stall instead of misreading it as engine perf.

Engine v5.0.13

Released June 12, 2026

  • Boardwalks built down slopes with tight switchback corners now render coherently — no more railing bars shooting past hairpin turns or flickering planks on long straight runs.
  • Savi editing scripts while you play no longer makes the world's grass and decorations flash and hitch — the scatter only rebuilds when you actually change the decorations.
  • Calling an ObjectAPI method that doesn't exist — whether it was removed in an engine update (like api.patch, removed in 4.5.2) or never existed at all (like api.removeObject) — now tells you the real method to use instead of a bare "is not a function".
  • When a script calls an old removed API name, the error now tells you the new name to use instead of a bare "is not a function".
  • The breeze is back! Grass, flowers, and reeds with wind on them sway again — bases planted, tips swirling in the wind you authored.
  • Screenshots no longer hitch the game: when Savi peeks at your world, a thumbnail gets captured, or you snap a shot for chat, the frame used to stall for ~8 frames mid-gameplay. Captures are now visually identical and nearly free.
  • Platformer fix: characters now fall off platform edges cleanly instead of stuttering up and down at the lip. Walking down slopes and stairs still feels glued, exactly like before.
  • A spotlight given a too-big cone angle (like an angle in degrees) used to go completely invisible with no error. Now it lights up at its widest cone instead, and Savi gets a log telling her exactly how to convert the value to radians.
  • Games with rich terrain no longer gray-screen on GPUs with strict texture limits — the engine now simplifies terrain shading on those machines instead of failing to draw the world.
  • Placing objects on the room grid now works in every spawn shape the docs show: spawn({ properties: { feetPosition: { tile: [x, y] } } }) (with optional offset) lands on the tile like the 3d-rooms guide says, including inline children. Mistyped tile positions get precise messages instead of a generic rejection, and using a tile position in a world without rooms terrain tells you why the object landed at the origin.
  • Walking through translucent things — light shafts, ghosts, holograms, glass effects — no longer slices the screen with hard edges. They now dissolve gracefully as you get close, exactly like you'd expect.

technical notes

  • Spline corner joins now apply a miter limit (SPLINE_CORNER_MITER_LIMIT_RATIO = 2): the exact offset-line intersection is kept for turns up to ~127°, sharper corners fall back to a bevel. Pre-fix, hairpin corners extended laterally-offset bars (boardwalk/conveyor rails, corner fill polygons, stair corner landings) by offset * tan(turn/2) — unbounded, e.g. ~2.6m floating rail spears on a switchback boardwalk.
  • Hard-corner boardwalk segments no longer emit duplicate plank/rail-post/pile boxes at collinear subdivision boundaries (exact coincident twins that z-fought).
  • Fixed the live-edit decoration flash (dump 97f8b3c0): setTerrainDecorationConfig now content-guards at the renderer boundary — a re-emitted terrain/decorations op whose config is structurally unchanged keeps its revision instead of bumping it, so the scatter rebuild guard in syncTerrainDecorations stays satisfied and the full teardown (clearEntries + synchronous material compile) only runs on real config changes. The compare is a structural walk over the modest JSON-shaped config (key-order-insensitive, undefined-valued keys count as absent), run once per decorations op, never per frame. Real changes — layer add/remove, item edits, budget/settings changes — rebuild byte-identically to before.
  • Classified tome/resim-telemetry-emitter as live-channel in resource-dispositions.ts. The resource is new in 5.0.12 (fleet resim telemetry) and was hitting the exec snapshot builder's plain-data gate ~30x/hr in prod ("clone disposition but failed the plain-data gate (function value)") — the same post-audit class as tome/scratch (#7143): it landed after the #7057 declared-type sweep, and the value is a room-runtime logger closure (the spec-push-notifier pattern). Behavior is unchanged — the gate was already shipping it as absent, and absent matches the resource's own contract: it's installed by the server room runtime, absent on clients and singleplayer glue, and resimTelemetrySystem probes-and-degrades (no emitter → bail before touching state). The live realm reinstalls it at room init; nothing about it can or should ride a snapshot back.
  • Sibling sweep of every resource registered between the 5.0.11 and 5.0.12 mints: tome/runtime-cursor-override (boolean), tome/sound-durations / tome/sound-duration-reoffers / tome/pending-sound-ends (plain Maps/arrays of scalars) are all plain data and correctly default to clone; no other function-valued resource is unclassified. The dispositions test's audit list gains the emitter, and a snapshot round-trip test pins no-warning + absent + system-no-op-after-restore with the closure-bearing emitter installed.
  • Classified tome/scratch as re-derive in resource-dispositions.ts. It was the remaining resource hitting the exec snapshot builder's plain-data gate in prod ("clone disposition but failed the plain-data gate (function value)" — the #7057 sweep audited by declared type, and TomeScratchState declares plain Maps/Records, but arena values are script-owned and games stash closures in them at runtime). Behavior is unchanged — the gate was already shipping it as absent, and absent matches scratch's own contract: arenas are realm-local working state, ensureScratchState lazily re-creates empty state on first worker access (scratch.ts documents lazy init as its restore path), and scratch writes are definitionally non-transactional so they never ride the merge log back. This makes absent-by-design explicit and silences the per-dispatch error log.
  • Prod log audit of the gate-failure pattern (7 days): only tome/quality-landing-reset-broadcaster (fixed in #7057, noise continues from pinned pre-#7057 engine versions) and tome/scratch (this change). The dispositions test's audit list gains tome/scratch, and a snapshot round-trip test pins the no-warning + absent + worker-side-fresh-arena behavior.
  • The removed-API tombstones (src/tome/api/removed-api-tombstones.ts) now also cover the top dead ObjectAPI names from prod failure mining (~1,100 calls/week T7d): patch (×443), patchObject (×173), removeObject (×156), patchPlace (×88), despawn (×86), setSpec (×76), updateObject (×73), patchSpec (×64), readScript (×15), readFile (×3). Calling one now throws a TypeError that leads with the name and gives the exact replacement call shape (e.g. api.removeObject was never an ObjectAPI method — use api.destroy(id); the setSpec/patchSpec family teaches the per-slice patch verbs; readScript/readFile teach getScript(path)/listScripts()). All but one are PHANTOMS — methods that never existed — and their messages say "was never an ObjectAPI method", not "was removed": the message is the teaching, so it doesn't lie about history. The exception is api.patch, which is real history: it shipped as the generic dot-path dispatcher in engines 4.4.0–4.5.1 (#6417) and was un-merged into the discrete patchX verbs in 4.5.2 (#6568) — prod apps pinned to those engines still run it today. Its message says "removed in 4.5.2" and teaches per intent: own state → patchState; other objects → setObjectProperty/batchSetObjectProperties/patchObjectState; spec slices → the per-slice patch verbs; dot-path spec writes → the matching slice verb. Same mechanics as the original tombstones: non-enumerable call-time throwers installed once per world prototype, no Proxy, zero new code on live call paths; every phantom was verified absent from the current api surface (the once-removed now-live patch* slice methods stay live and unshadowed, guarded at world construction), and a new test asserts every verb a tombstone message teaches is itself a live method — a tombstone can never teach another phantom. Also fixed: updatePlace()'s non-keyed-map "objects" warning taught spawnObject/removeObject/updateObject (two of which are phantoms tombstoned here); it now teaches spawn()/destroy()/setObjectProperty().
  • Calling a removed ObjectAPI name (raycastPhysics, raycastPhysicsAll, raycastPhysicsDown, getAimDirection, getPointerDirection, getPointerRay, getAimOrigin, directionFromYawPitch, rotationFromDirection, destroyObject, patchEphemeralState, setEphemeralState, replaceEphemeralState, defaultState) now throws a TypeError carrying the migration path from the consolidation changelog (e.g. api.getPointerRay was removed — the cursor ray is api.getInputRay(input), including the maxDistance → distance / ignoreIds → ignoreEntities option renames for the raycast trio) instead of a bare "is not a function". Tombstones are non-enumerable call-time throwers installed once per world prototype (src/tome/api/removed-api-tombstones.ts) — no Proxy, no compat alias, zero new code on live method call paths. Both behavior hooks and run_script (including read-only) are covered; the enriched message flows through the existing behavior-fault DM unchanged. A construction-time guard throws if a tombstoned name ever returns as a live method.
  • Resimulation stats: the expired-sample sweep (prune) on the record paths is amortized to once per second instead of running on every record/recordPushDelivery/recordBaselineAdopt/recordResimDeferral call (3.3% of sim busy during correction storms). Snapshot output is byte-identical — retention (2s) exceeds the reporting window (1s) and snapshot() still prunes unconditionally before reading; pinned by test.
  • Restored terrain scatter wind displacement lost in the renderer deslop (#6517): wind: { force, scale, speed } on grass/sprite decoration items sways the cards again. The GPU-scatter card material now installs an mx_noise_vec3 displacement (instance-world-position seeded, time-scrolled, masked by normalized card height so the base stays pinned) whenever a usable force is authored — windless items compile the exact same node chain as before, zero added vertex cost. Old defaults preserved: scale 0.8, speed 1.0, force 0 (wind without a force does not move). Displacement is branchless WGSL (select()-lowering hazard) and pinned by a dominance-analyzer suite.
  • Viewport screenshot capture (captureRendererScreenshot) downscales BEFORE encoding instead of after: the frame's ImageBitmap is drawn onto an OffscreenCanvas capped at 1280px on the long edge and encoded as JPEG q0.92, replacing the synchronous full-resolution PNG encode that ran on the render worker (trace-proven at ~137ms / ~8 dropped frames per capture on a retina viewport, fired by every chat/Savi/SEO screenshot). Every consumer already downscaled to ≤1280px JPEG on the main thread — chat 400px q0.5, scene views 768px q0.7, SEO/savi-note thumbnails 1280px q0.92 — so final outputs are unchanged while the hot-thread encode runs on ~10-20x fewer pixels with a cheaper codec (and the consumers' own decode gets equally cheaper). The sizing/format decision is a pure exported function (resolveScreenshotEncode); the 2D/viewport scene-view path reports the encoded dimensions.
  • Character controllers (rapier + mantle) no longer jitter at platform edges. Walking off an edge, snap-to-ground (a full-capsule shapecast down) re-caught the platform lip while only the capsule rim still overlapped it: grounded=true zeroed the motor's accumulated fall velocity, one gravity quantum followed, and the snap caught again — a self-sustaining loop (every catch re-armed the snap grace window) that held the character hovering at the lip in 1-frame gravity quanta instead of falling. Both controllers now require support under the capsule AXIS (an inner-radius down-probe within snap reach) before a snap commits or a contact classifies as ground; a rim-only lip catch reads NOT grounded and never re-arms the snap grace. Slope descent, stair descent, step-downs within snapToGroundDistance, and flat-ground walking are unchanged (the probe hits the surface that continues under the axis) and pinned by parity tests in both engines, 3d and 2d-side. No new cross-tick state: the probe is a pure function of position + world geometry, so prediction/resim determinism is untouched.
  • Standing astride a seam or gap (two platforms, tops level, character axis over the split) stays grounded. The axis probe alone demoted that stance to airborne forever — jump denied once coyote drained, air-pose while standing, NPC isGrounded deadlock, ride-velocity cut on seamed moving platforms, and unbounded phantom fall-velocity accumulation. The rapier probe now has lateral width (offset rays at mantle's thin-capsule ratio, so centimeter seams between abutting blocks can't be threaded) plus a straddle probe (rim-height support on BOTH sides of the footprint counts; one-sided rim support still demotes, so the lip fix is intact). Mantle falls through to its wedge test when ≥2 opposing bottom contacts exist — opposing rim contacts that block descent ARE support — with the down-probe run in the support's reference frame so descending platforms keep their riders.
  • Spot light angle is now clamped to THREE's defined cone domain (0, π/2] at the engine's single THREE.SpotLight.angle write (setSpotLight). Above π/2 the cos-based smoothstep cone edges invert and BOTH lighting paths (clustered packer + legacy dynamic) render exactly zero light inside the cone — the prod invisible-flashlight class, where a degrees-shaped angle: 26 silently killed a creator's spotlight across 9 spec versions. Degrees-shaped values (> π/2) additionally report once per light on the engine diagnostic rail (light-spot-angle-clamped, getLogs + one-time DM) with the copy-pasteable radians fix; non-positive angles clamp to the 0.0001 epsilon floor (THREE's smoothstep degenerates at exactly 0) without the degrees message. Authored spec values stay untouched — the clamp is render-side only. The api-reference skill now states radians explicitly on the LightSpec line, the spotLight builtin, and the spot example.
  • Gray-screen class fix (partner report, game "Higher"): a terrain pool material whose pipeline the preflight resource guard rejects (granted maxSampledTexturesPerShaderStage exceeded — the WebGPU spec minimum is 16) no longer aborts every frame forever. The renderer walks a texture-budget ladder (degradeTerrainTextureBudget): rung 1 rebuilds every pool on the simplified lit variant (LOD>=2 resolve, PBR/NRO compiled out — 3 fragment-stage bindings), rung 2 on the unlit floor (material.lights = false — no cluster/shadow/IBL taps at all). Scene-level rung; pools created later inherit it. Each rung reports through the engine diagnostic rail.
  • Non-terrain materials that exceed pipeline resource limits are now hidden (object.visible = false) instead of re-throwing per frame, so one over-budget material can no longer blank the whole scene.
  • New suite pins the terrain material's per-stage sampled-texture count by compiling the real WGSL: a maximal library (PBR + NRO + noise + gradient + height tint + emissive) binds exactly 5 fragment-stage textures, and the worst-case lit stack stays ≤ 16 on every tier.
  • spawn()'s feetPosition validator now admits the { tile: [x, y], offset?: [x, y, z] } position shape that resolveRoomsTilePosition already resolves and the 3d-rooms skill teaches — previously the gate layer rejected the shape while the writer layer fully supported it, failing whole spawn batches in room worlds. Tile coords must be two integers (the ASCII-grid indices the resolver looks up; fractional coords would silently skip the floor-height lookup), offset must be [x, y, z] finite numbers, and stray keys reject by name. Covers every spawn shape: direct spawn(), inline children recursion, and setProperty("feetPosition", ...).
  • Tile positions in a place WITHOUT 3d-rooms terrain still resolve to the origin (the fallback is load-bearing — interpreter phases must never see a throw), but no longer silently: a teaching warn lands in the runtime log, once per place per world.
  • spawnFx and damageNumber — the two position entry points where tile positions genuinely can't resolve (they take raw world points) — now say so explicitly when handed a { tile } shape, instead of a generic shape rejection.
  • Mesh materials in the ghost/shaft class (transparent && depthWrite:false, depth-tested) now compile a camera-proximity alpha fade: fragment alpha ramps to 0 by 0.5m from the camera and is EXACTLY 1 at/beyond 1.5m (WGSL smoothstep — far-field byte-identical). Fixes the near-plane degenerate case for creator-built god-ray shells / ghost meshes: the shell wall no longer sweeps the screen as a hard-edged clipped polygon when the camera enters it, and stacked-shell brightness no longer pops stepwise wall-by-wall (each wall now dissolves through the ramp). Applied on both construction paths: standalone mesh node materials (createMeshNodeMaterial/...FromSource, re-settled after GLTF source flag copy) and the transparent non-overlay primitive batch lanes. Opaque, depthWrite:true, depthTest:false (overlay), and appearance-owning (water/shockwave/slash/scripted) materials keep node graphs untouched. Fade is alpha-only (no emissive hue shift), branchless TSL (no select()/toVar — r0.184 lowering hazard), and dominance-analyzer pinned.
  • Scene-depth ("soft particle") intersection fade deliberately not built: the only scene depth reachable from a scene-pass material is viewportDepthTexture, whose mid-pass framebuffer grab forces the MSAA store every frame such a material is alive (viewport-share, ledger #253/#332) — a standing perf tax on every world containing a ghost mesh. Hard floor/prop intersection seams at a distance are unchanged.

Engine v5.0.12

Released June 12, 2026

  • 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 all playSoundAt one-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; explicit AudioListener entities still take precedence.
  • playSound closes its open loop: behaviors can export an onSoundEnd(sound, api) hook that fires exactly once when a non-looping sound the object played finishes, with sound.durationSeconds carrying 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 generic cmd.* 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. playSound now 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 fire onSoundEnd (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 raw source.loop = true wrap 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 sceneHiddenAtStart option (set by the worker browser host) and a one-way markSceneVisible() 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_failed log (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 worker error/messageerror/close are 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 whose postMessage throws on a live handle counts as death. Default WORKER_HEARTBEAT_TIMEOUT_MS lowered 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) and prediction/resimulation-stats (staging, 5.0.11 gate — the ResimulationStats class instance the client prediction loop owns). Both are now live-channel in resource-dispositions.ts: the exec worker can never signal clients or run the prediction loop, and both read sites already probe-and-degrade (requestQualityLandingReset falls 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 createResource token in src/ for the same latent class (function/closure/class-instance/handle-bearing values defaulting to clone): 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-telemetry server system (scheduler everyN cadence, ~60s at the 30Hz default sim rate) folds the existing client-health mirror — the validated per-client prediction windows that already cross on the engine.clientHealth command every ~15s — into one room-level resim_telemetry structured log line (clients, mismatch%, drift/push/skew split, mean+worst corrections/s and resim ms/s, merged top-5 offender components, quaternion double-cover phantomSuspect heuristic). 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, ONE resim_recording event 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.ts now 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-reference ik entry'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 to ik.
  • 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-After via x-magic-cdn-async instead 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, new magic-cdn-async.ts probe.
  • 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 (mountTomeUICore et al). Engine-initiated unlocks no longer mis-arm the browser gesture gate, so authored pointerLock flips and UI-close relocks never demand a pointless extra click.
  • Live spec flips of camera.pointerLock now 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: true explicitly (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 for transform/world-rotation (vectorBlend quat axes) and physics/body-state.rotation diff 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 to MAX_RESIM_TICKS back could fall off the ack ring, and the lookup miss degraded the replay to the replayFromTick = mismatchTick fallback (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-schemas themselves — 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 the sizeOverLife frame-killer cannot recur inside a gated family). Two tiers derived from zod issue codes: a direct invalid_type with 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.collider widened 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: unknown material.kind strings (e.g. "glass") now validate down the standard-material path the engine actually applies (with a teaching warn); particles.textures accepts any string (preset aliases like "blood" resolve, other strings load as a single texture path — what the runtime always did); particles.shape gains kind: "disc" (radius/jitter/lift), now plumbed end to end through EmissionShape, 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); frame carries { index, animation, frame, count, t, size } with t = frame/count as 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 static meta export (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/height on terrain: { kind: "tilemap" } are now optional. Omit both and tileAt({ 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/resetTile overrides ride their replicated chunk entities and survive eviction. Unbounded maps anchor cell (0, 0) at the world origin (generator coords = floor(world / tileSize)); authored width/height keep today's bounded, place-origin-centered behavior byte-identically. getTile is 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 optional opts.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 existing TomePlayerJuiceState transport 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.

Engine v5.0.11

Released June 11, 2026

  • Savi's script tools work again in solo games with mouse-look cameras — a 5.0.10 regression broke her ability to run scripts there.

technical notes

  • The exec worker's snapshot rebuild now registers the client-runtime components for client-mode worlds (ledger #595, 5.0.10 regression). 5.0.10 moved run_script off the sim thread into an exec worker that rebuilds the world from a WorldSnapshot, with a registry built from game.schema(...) + feature glue only — but the client runtime worker registers pointer-lock intent and the selection components OUTSIDE that path (runtime-worker.ts define). In singleplayer the client world is the authority, so any mouse-look game's snapshot shipped an input/pointer-lock-intent column and every run_script faulted deterministically with "snapshot component 'input/pointer-lock-intent' is not registered in the worker schema — game module mismatch" (field hit: app 463cbf9c, creator rolled back to 5.0.9).
  • One source of truth: the registrations are extracted into registerClientRuntimeComponents (engine/components/client-runtime.ts), called by BOTH the runtime worker's define and createSnapshotWorld. The snapshot rebuild registers them for worldMode === "client" only — server snapshots never carry these rows and server registries are byte-for-byte unchanged. The loud unknown-column throw stays for genuinely unknown components (no silent column skipping).
  • New real-path pin client-runtime-components.test.ts: a singleplayer-shaped client world built through the exact runtime-worker registration split, a pointer-lock row on the camera, run_script through createExecEndpoint → createSnapshotWorld — red on the unfixed build with the field's exact signature; plus a selection-row case and a still-throws case for unknown columns.

Engine v5.0.10

Released June 11, 2026

  • Fixed a bug where talking to Savi with your voice could get stuck in a silent retry loop if the microphone pipeline failed to start — it would quietly hammer away forever and flood our error logs while voice just didn't work. Now it tries a few times, tells you plainly that voice is off for this session, and typing keeps working (reloading the page may bring voice back). Quick tap-and-re-hold on the mic can no longer be mistaken for a real failure.
  • If music modules ever fail to load in a game, the game now says so once and keeps playing everything it can — including .grain notes, which fall back to plain sample playback — instead of silently re-downloading the broken piece all session or delaying the music while it retries.
  • Fixed the "suddenly I'm sprinting at 10x, then my game breaks" multiplayer bug: when a game server fell badly behind, your character could rubber-band violently and your controls could go dead for minutes. The game now notices within a couple of seconds and snaps cleanly back in sync.
  • Your inputs can no longer get silently eaten after one of these episodes — movement and actions land again as soon as the game resyncs.
  • Your game never freezes while Savi works anymore: her scripts now run beside the game instead of inside its heartbeat, so players keep moving even while she rebuilds half the world.
  • Runaway scripts get stopped cleanly instead of locking the room, and Savi gets told exactly what happened so she can split the work up.
  • Long-running scripts no longer undo things that happened while they ran — if players scored points or took damage mid-script, that progress survives.
  • Edits you make in god mode while a script is running are safe now too: the script re-runs against your latest version instead of overwriting it.
  • Cars and other physics-driven builds hold together now: body panels spawned onto a moving chassis no longer freeze in place, drift apart from the wheels, or vanish entirely after a repair pass. Rebuilding a vehicle while a script error is being fixed no longer produces a dead, invisible car.
  • No creator-visible changes.
  • Changing world settings mid-session (like switching the physics engine) no longer makes script-built vehicles and contraptions fall apart — all their parts stay attached.
  • When a generator script can't run, you and Savi now get one clear message naming the script and what to change — instead of the same cryptic error repeating forever in the background while the object silently renders flat.
  • Generator scripts written with export const / export class just work now; ones using import get told exactly how to switch to require().
  • Cars built from primitive parts under a vehicle parent exist again — the engine's own default for those parts was being rejected by its own validator, so the body pieces silently never spawned and you got an invisible chassis driving around.
  • When a build script does fail while Savi runs it, the error now comes back to her instead of disappearing — she can see what broke and fix it instead of telling you it worked.
  • Loading placeholders are now clean holograms — the translucent bubble around each generating object is gone, and overlapping holograms now layer correctly by distance instead of in arrival order. Scenes spawning many assets at once no longer fill the view with overlapping bubble shells while things build.
  • Savi can now see how your game actually runs on each player's device — including when a device quietly lowered its own render quality in a past session and kept it that way. If your game looks blurry for no reason, ask her: she can check it and (with your OK) reset that device's quality so it re-measures fresh.
  • Savi can also measure multiplayer smoothness now — when something rubber-bands or feels laggy, she can see exactly which part of the game keeps correcting and fix the right thing.
  • Singleplayer mode no longer drops fps while moving on terrain games — the engine was building and tearing down the same far terrain ring about once a second, and now it doesn't.
  • Worlds with animated sprites (2D characters, billboard NPCs) no longer stutter from constant prediction corrections — sprite animation is smooth even in multiplayer.
  • Fixed a bug where pointing a 2D tilemap's tileset at an animated texture turned the whole game black — every tile now draws that texture's first frame instead, and the game keeps running.
  • Fixed: in singleplayer, a hitch (a big particle moment, assets installing, shaders compiling) could get blamed on whatever scripts happened to be running, parking perfectly innocent scripts for 10 seconds with a "blew its tick budget" warning. The watchdog now tells the difference between "your script was slow" and "the game hitched around it" — genuinely heavy scripts still get caught exactly as before.
  • Fixed a bug where players with a profile character loaded their 3D avatar into 2D games — if your game gives the player a sprite, the sprite is what everyone wears now.
  • Fixed a bug where dying to an enemy could leave you permanently dead: if an enemy's attack used the built-in combat module, your own death code (the "YOU DIED" moment, the respawn timer) never got a chance to run — the game just stopped responding to movement with no message. Your script now always sees your health hit zero and gets to decide what death means: respawn, game over screen, spectator mode — your call.
  • Fixed players jittering or getting yanked around while standing on props (docks, bridges, scattered trees) in multiplayer — the ground under you is now exactly as solid on your screen as on the server.
  • Default terrain stops looking like wet plastic. Ground built with pbr: true materials now reads as matte dirt, grass, and rock under full sun — the auto-derived gloss maps were trusted too much and most ground was riding the engine's gloss floor. The texture detail and natural variation all stay.
  • The roughnessIntensity knob now does what its docs always said: 2 is genuinely fully matte (it used to stall at semi-gloss on most textures), 1 uses the derived map as-is, below 1 is unchanged for wet/polished looks.
  • Loading holograms now sit on the ground where the object will appear instead of hovering in mid-air, and grow upward in place as the asset's real size becomes known.
  • Shrink and grow mechanics now feel right out of the box: when a script scales the player, the camera follows the body — first-person eyes drop to the new height and third-person cameras orbit closer — instead of staying stuck at full-size height.
  • Worlds Savi builds now come in noticeably lusher: flower beds, undergrowth, and ground cover are taught ~50% denser and ~50% bigger, and unsized decoration sprites default to a size you can actually see. Bare-ground-showing-through forests should be much rarer.
  • Savi now knows how terrain ground cover actually scales: she builds dense carpets from several plant variants per layer instead of cranking a density number that silently did nothing.
  • Fixed a bug where terrain textures could randomly fail to appear when a world mixes texture sizes — some or all ground materials rendered as flat solid colors depending on download luck. Every texture in a mixed-size set now always shows up.
  • A 2D tilemap floor whose art is still generating (or failing to fetch) now shows a quiet gray loading wash instead of disappearing into black. Your art swaps in the moment it finishes cooking — no reload needed.
  • Savi mixes her soundscapes properly now — ambient beds (wind, water, hum) sit quietly under the game instead of blasting at full volume, with sound effects spiking above them.
  • Debug reports now include a snapshot of what the renderer is holding (draw calls, triangles, shader and texture counts, GPU memory), so when a world slows down we can tell whether something is piling up over time or the scene is simply heavy — straight from the report, without asking you to reproduce it.
  • Savi's edits now read back correctly: getSpec() in a script reflects every property change earlier scripts made this session, instead of the world as it was when the room booted. Read-modify-write edits (like tweaking a scatter you set up a few messages ago) no longer silently undo earlier work.
  • Terrain generators written as heightAt(x, z) or heightAt(x, z, ctx) now just work — the engine recognizes the shape and routes position and helpers correctly. (The documented contract stays heightAt(ctx).)
  • A broken terrain generator can no longer fill your world with mountains you never wrote: faulted samples render flat ground and Savi gets told exactly which function failed and why.
  • If you open your game in two tabs with the same account, the older tab now tells you the game moved to the new tab instead of silently eating your clicks — buys and moves no longer "happen" and then undo themselves in the tab that lost the session. Fixed a rare state where outdoor worlds lit by the sky (no authored ambient light) could lose their ambient fill for the whole session — shadows rendered near-black under a clear sky. The sky's light contribution now continuously self-heals, so even if a frame of it is lost, the world recovers within seconds.
  • Things placed on the ground now stay on the ground when the ground changes. Fences, roads, scattered props, and anything placed at terrain height follow the terrain when it's reshaped — no more boats or race tracks floating in the sky after a terrain fix.
  • Objects you placed at an exact height stay exactly where you put them — the engine only moves things it placed on the terrain for you.
  • If a terrain script has a bug, the ground now stays honestly flat and Savi gets told exactly what's wrong (including the classic heightAt(x, z) instead of heightAt(ctx) mix-up) — instead of the engine quietly inventing hills that everything gets built on top of.
  • Side-view platformer cameras hold a steady zoom: jumping no longer makes the screen breathe in and out. (Cameras with an explicit zoom keep it, as always.)
  • vignette(x) now reads like the knob says: low values draw a subtle rim at the screen edge and 1.0 closes in for real drama. A hidden boost used to max it out early, so if your game has a vignette it will look different — better.
  • Rapid-fire sounds stay punchy: when the same sound effect is spammed past its voice limit, the newest shot always plays (the oldest copy fades out underneath it) instead of new shots going silent.
  • Sprite animations play at their authored speed — no more temporary 8fps guess sticking around after the real animation data arrives.
  • Joining a multiplayer game no longer flashes your character down to a tiny sprite for a moment.
  • Visitors who open a game that hasn't been published yet now see "This game hasn't been published yet" right away instead of an endless loading screen. Publish your game and the link works on the next load.
  • Color grades no longer crush dark scenes to black, and vignettes are round by default — moody looks keep their detail.
  • A script error inside a timer or promise can no longer crash your game's server — you'll see the real error message in the script result instead.
  • Parked cars drive again the moment you hit the gas — vehicles that had settled into their power-saving sleep no longer ignore the throttle.
  • Animations no longer get stuck after a jump: the "jump pose while walking" wedge (a script-side cache surviving a multiplayer prediction replay) can't happen anymore — animation calls are now free to make every tick, and Savi's playbooks build platformer animation that way from the start.
  • Jump arcs animate cleanly through the peak: the taught pattern covers the whole airborne range, so sprites no longer flash their walk or idle pose at the top of a jump.
  • Cars no longer freeze after you hop in: a heavy moment earlier in a script's life (or a busy server) could quietly pause an object's update loop for 10 seconds, and if you mounted a vehicle during that pause it would ignore the gas pedal entirely. Taking control of an object now always lifts that pause immediately.

technical notes

  • AudioWorklet load-failure ladder (ledger #451): vibe worklet module loading (sidechain-follower.js, granular-player.js) is now memoized per AudioContext with a bounded retry budget (3 attempts, 2s/8s backoff) and a terminal verdict. Previously every Sampler fired its own addModule pair, so a game that stops/starts vibes re-fetched — and re-failed — the modules for the whole session. A terminal-failed context re-arms exactly once when a later vibe restart asks again (a transient inside the original ~10s retry window shouldn't condemn the whole session); after that the verdict is permanent, so scripted restart loops can never become the retry storm again.
  • Vibe start never waits out the backoff ladder: startVibe gates on the FIRST worklet attempt only (Sampler.workletFirstAttempt). On a healthy context that includes the follower wiring (grain + ducking live before cycle 0 — the pre-ladder behavior); on a failing context, synth/buffer playback starts immediately while retries run in the background and .grain notes fall back to plain buffer voices until the ladder resolves.
  • Sampler.workletReady never rejects anymore. A rejecting promise left floating by early-return paths (vibe compile failure, dispose during load) was an unhandled-rejection source; failure is now a state (workletsAvailable: false) instead of an exception, observable via the new onWorkletLoadFailure hook.
  • Terminal worklet failure is honest degradation, not silence or a loop: synth and sample playback keep working; sidechain ducking is off and .grain triggers fall back to plain buffer voices (pitch shifts then also change duration), which complete and release through the same voice paths as everything else (the #213/#214 release invariant). The creator-facing message says exactly that — degraded, not disabled.
  • Exactly one diagnostic per failure, never spam: the renderer routes the first worklet failure per renderer into the vibe error rail (vibe.worklets — the same rail Savi's other vibe errors ride), and one console.error rides the iframe→parent error-forwarding rail to Datadog.
  • The vibe-compile failure path in the audio renderer now disposes the half-built Sampler (told-failure ⇒ released) instead of leaking its node graph and in-flight worklet load.
  • kiln (rides the same merge, deploys with kiln): the studio voice push-to-talk startup had the live prod shape of this bug — the Tab-hold prewarm re-armed a failing startup on every status change, re-running the entire capture pipeline (new AudioContext + getUserMedia + token fetch + worklet fetch) every ~120ms-plus-failure-latency for as long as the key was held, and the unawaited worklet/token/resume promises sprayed unhandled "Unable to load a worklet's module." AbortErrors (95 in one creator session). Startup failures are now bounded (3 real failures → voice off for the session with a plain-language message + one diagnostic), cooldown attempts are silent no-ops that can't re-trigger the prewarm loop, user cancellations are classified as cancellations (no error toast, no budget burned), and permission denials keep their existing dialog flow without ever disabling voice.
  • kiln voice startup attempts carry a per-attempt epoch: a cancelled attempt whose parked promise (getUserMedia / token fetch / worklet) settles after a newer attempt started is recognized as stale — it releases only what it acquired locally and never burns the failure budget, touches the new attempt's refs, or latches the terminal state. Failure and terminal messages now actually render on the studio's sonner toast rail (one fixed toast id — repeats update in place, the terminal verdict shows as exactly one toast).
  • Prediction clock-runaway proactive recovery (ledger #453, dump de3f253e — first during-event capture of the super-sprint/resync class; same mechanism class as #380/#398/#416): when the server's sim clock falls behind wall clock (#333-class stall — the backlog clamp eats the missed time, so the tick clock never catches up) while its authoritative feed stays alive, the client's prediction clock keeps tracking wall time and runs past every reconcilable tick. Recovery used to wait for an accidental resim anchor — which requires a drop-acked ACTION frame — so an axes-only (walking) player wedged for minutes: zero compares possible (server rows fall out of the client oplog window), input dropped server-side, then a missing-data adopt storm anchored on client-domain ack ticks (the dump's mismatchTick=6089 against a server whose newest authoritative state was 3114). The client now detects the breach directly — localTick beyond newestAuthoritativeTick by more than the RTT-aware join lead + 2× the resim cap, sustained for a full cooldown window (30 ticks) with at least one authoritative arrival — and runs the existing #333 recovery (hard baseline adopt + clock rebase, reason prediction.clock-runaway) proactively. Recovery latency for the whole class drops from unbounded (minutes) to ~2–4s of onset. Dead feeds never trigger it (nothing fresher to adopt; free-run until data resumes).
  • Server input-buffer tick-domain guard (TickIndexedInputBuffer): the consumption horizon could adopt the client's runaway clock domain — rebaseOnTooFar (empty buffer) and the post-reset baseline seed both set lastConsumedTick = frameTick − 1 from a frame stamped thousands of ticks past the server's own clock. That pinned the too_late floor in the server's far future, so every frame the client sent AFTER its clock rebase was dropped too_late (and drop-acked at client-domain ticks, re-arming the missing-data storm) until the server's sim caught up to the runaway tick. The buffer now anchors the horizon to the server's own consume clock: frames stamped beyond one buffer horizon of the last consume tick are dropped too_far and can neither seed nor rebase the baseline. Idle-resume rebases (frames near the server's clock against a stale horizon — what rebaseOnTooFar is for) are unchanged.
  • run_script moved off the sim thread (ledger #376, second half): Savi's room.exec now runs on a dedicated per-room exec worker (worker_threads on the server container, a nested Web Worker on the singleplayer authority) against a tick-consistent WorldSnapshot. The script's staged effects return as a serializable TransactionLog merged into the live world in ONE transaction at the tick boundary (order 8, the same point commits always landed) — the simulation never blocks on script execution. Design doc: docs/exec-off-sim-thread.md.
  • Busy-loop kill switch: a script that stops responding past the 5s synchronous budget gets its worker terminated and respawned lazily — the pure-JS busy-loop class that in-thread budgets (#6870) could only document now dies. The watchdog's budget clock starts at the worker's "started" ack, posted AFTER the SnapshotWorld rebuild, so heavy-room rebuild time bills to the rebuild allowance and never kills a within-budget script; rebuild time is measured in-worker and rides exec telemetry as rebuildMs.
  • Granular merge: state bags merge per top-level key, child lists merge by membership, monotonic id counters advance instead of overwrite, append streams ship only their suffix — a multi-second exec can no longer revert concurrent sim progress on state it merely read or partially touched.
  • Spec gate: a merge whose log touches spec state is gated on the spec not having moved since the snapshot — revision + dbVersion + replace-epoch, where revision catches god-mode authoring and behavior-driven spec writes that deliberately preserve dbVersion. A moved spec transparently re-runs the exec against a fresh snapshot (bounded at 2 retries, then an honest conflict error); results carry specMovedDuringExec.
  • Deadline honesty: entries that cannot start (projected queue pressure) or whose results land past their caller's deadline fail fast with honest errors instead of timing out opaquely upstream — a told-failure exec never applies.
  • Snapshot caching: the persistent per-room worker retains the spec by revision and terrain chunk outputs by build signature; steady-state dispatch ships only what changed. Idle workers reap after ~10 min (room-reaper idiom) with lazy respawn.
  • Exec telemetry: snapshotMs (collection + postMessage serialize — the full dispatch stall), snapshotBytes, workerExecMs, rebuildMs, mergeMs, mergeConflicts, queueWaitMs, identitySkippedPatches, workerTerminations recorded per exec.
  • notifyDm in scripts is now delivered with the result (was mid-exec) and still survives rollback — failed scripts keep their diagnostic breadcrumbs.
  • New engine bundles: engine-server-exec-worker.mjs and engine-client-exec-worker.mjs, built and published with the existing artifact pipeline.
  • Ledger #409 (BT's night street racer, dumps 17e3406b / 4df328fc): the player-vehicle assembly broke three chained ways, all from one root — the primitive helpers' parent-aware physics default emitted physics: false, a shape validateSpawnSpec rejects. Every p.box/slab/cyl/... under the car's chassis mount threw spawn(): physics: expected { body: ... } and faulted the whole update() hook, so the car shell never assembled (the invisible car). The original test stubbed the api and asserted the resolver's output without ever passing it through real spawn. The resolver now emits { body: "none" }, and physics: false is additionally accepted everywhere as "no body" (it was the taught-by-error shape, and a natural model guess).
  • The parent-aware no-body default now covers ALL primitive spawn helpers — cone, sphere, torus, pyramid, ellipsoid, ring, plane, circle, torusKnot, hemisphere (+dome/bowl), tube, and the polyhedra had kept the old unconditional pattern and silently minted static colliders inside simulated assemblies. model keeps its no-physics-key default (already body-less under vehicles).
  • Fault scopes no longer survive a same-id respawn: faults are keyed by entity id and the lazy stale-clear in isFaultScopeDead only fires while the id is unoccupied — a destroy + respawn at the same id inside one exec/run_script transaction never opens that window, so the fresh instance inherited its dead predecessor's faults and its onSpawn was silently skipped. In #409 that minted the zombie car: no vehicle physics at first update, so parentHasSimulatedBody read no simulated root and every body panel spawned with the STATIC fallback — BT's wheels-drive-away-from-the-body separation plus a ~25 mispredict-ticks/sec resim storm (104 corrections/282 ms of resim per second in the dump). With reuseExplicitSpawnIds + the hook-mint id lane, later rebuilds ADOPTED the poisoned panels, so the static bodies survived Savi's repairs and the player's reloads. ObjectAPI.spawn's fresh-entity path now clears the id's fault/park scopes (clearBehaviorFaultsForEntity).
  • Pinned red→green in tome/__tests__/ledger-409-car-assembly.test.ts through the REAL spawn pipeline: panels under a vehicle root spawn body-less with the hook completing; explicit physics: false spawns as no-body; setObjectProperty(id, "physics", null) removes the live body config; exec destroy+respawn-same-id keeps onSpawn-applied vehicle physics and the next shell build stays body-less. Session Lab scenarios ledger-409-car-assembly / ledger-409-respawn boot the user's exact spec through the production path and probe the live assembly.
  • Kernel error logs now carry the app they came from (ledger #369, identity half). The room-runtime worker — where every tome.behavior.hook_error originates — binds appId into its log context from the spec row it hydrates (the one source every hydration path shares: /update RPC, WS-first activation, respawned-worker re-hydration), and binds variantId from per-RPC SDK identity (live rooms have no env identity — the ledger #239 boot-env-is-immutable contract stands). The shared client-safe mount-fetch path reports identity through a new setTomeLogIdentity seam in tome-logger.ts; room-runtime installs the server sink.
  • Deduped behavior hook-error ops emission per worker boot (ledger #369, volume half). A broken creator script fails identically on every entity/tick it touches — generator games fired 150-350 identical tome.behavior.hook_error lines per worker respawn (94.7% of all kernel error events). logHookError (src/tome/hook-error-log.ts) now keys on (event, hook, scriptRef, number-normalized error class) and emits the first occurrence full-fidelity, one <event>.suppressed notice when repeats begin, then silence for the rest of the boot. O(1) per error; capacity-bounded with fail-open (untracked classes stay fully visible). Gates ONLY the Datadog/ops emission — getLogs, the Savi DM rail, and client behavior faults are separate pipelines and still see every error.
  • Dedupe lifetime = fault lifetime: applySpecOrThrow resets the hook-error dedupe table at the same seam as clearBehaviorFaults, so new spec content gets a fresh ops signal exactly like it gets a fresh fault slate (a fix-then-rebreak of the same error class reports again instead of staying suppressed for the worker's life). The exec endpoint resets per request — its realm never sees a spec apply.
  • The exec worker carries identity too (ledger #369 review): run_script and its in-worker onSpawn/onDestroy hooks execute on a separate worker thread since #6941, which previously had no winston wiring and no identity — hook errors there fell to the console fallback. The server exec worker entry now installs the winston logger into tome-logger and binds an identity snapshot (appId/variantId/roomId) threaded through createServerExecPortFactory at every worker spawn (watchdog respawns re-read the live values).
  • Datadog ddtags follow bound identity: winston's Http transport reads its intake path per request, so setGlobalContext now refreshes the ddtags whenever identity binds — tag-based DD queries (appId:…) see the same identity as the indexed attributes instead of a boot-frozen appId:unknown.
  • Container-thread + network-worker identity (ledger #369 review, coverage half): the runtime worker relays every identity bind (spec-fetch appId, SDK-config variantId) to the container thread via a log.identity message — the container previously learned appId only from /update payloads, which WS-first live rooms never send — and the container forwards it to the network worker, which has no identity source of its own.
  • Fixed live api.updatePlace(...) (any place-def edit — the mantle/rapier engine flip, gravity tweaks) severing runtime-spawned children from their parent. updateEntityFromDef reconciled TomeChildren against the spec's hierarchy pairs alone, removing the component from any object whose children were spawned at runtime (generator scripts, mounted players). hierarchy-solve's parent-dirty hook gates on TomeChildren, so every passive child of a moving dynamic body froze in world space after one updatePlace — the ledger #420 "wheels drove away from the car body" report (the wheels kept tracking only because the car script rewrites their local transforms every tick; the engine switch itself was innocent — cold-boot mantle was already clean). The reconcile now keeps current children whose TomeParent edge still points at the object and overlays the spec's authored children.
  • Geometry/terrain/spline generator compile failures now report ONCE per content version (ledger #369): compileTerrainGeneratorRef and compileSplineGeneratorRef cache failed compiles the same way compileGeometryGeneratorRef already did, and all three check the cache BEFORE re-running module compilation — a broken generator no longer re-books tome.compile.*_generator_failed / missing_library / module_failed diagnostics on every entity apply, chunk job, or per-tick editor pass. Failure entries self-invalidate on source change; lib edits still recompile via the dependency-aware invalidation sweep (spline/IK/brush caches are now included in invalidateBehaviorCache / clearBehaviorCache, fixing stale closures over edited libs).
  • ES-module syntax in generator scripts is repaired when trivially safe and rejected clearly when not: export const|let|var and export class prefixes are now stripped alongside export function / export default (shared stripModuleExportSyntax across geometry/terrain/spline/brush compilers), and residual module syntax (import declarations, export { ... } lists, export async function) produces a guidance error — "is written as an ES module… use require("lib/...") and top-level function declarations" — instead of the raw parser message ("Unexpected keyword 'export'") plus a silent flat/no-mesh fallback. The error rides the existing compile-error rail (runtime log + deduped Savi DM + client fault) and fires once at author time.
  • The storm-class log lines (geometry_generator_missing_geometry, terrain_generator_incomplete, spline_generator_missing_profile, and the three *_generator_failed events) now carry scriptRef, and the "missing function" messages say "must define a top-level geometry() function" instead of teaching export.
  • Once-per-content-version never swallows the report: a failure first compiled by a reporter-less caller (the per-tick selected-editor probes) stores its compile errors on the cache entry and replays them to the first caller that brings a reporter (compileGeneratorRefCached) — fixes a latent geometry-path bug where probe-before-apply ordering could eat Savi's only signal.
  • The terrain chunk-worker runtime (engine/features/terrain/generator-runtime.ts) strips the same export forms as the spec-apply probe, so a generator the probe accepts can't silently fall back to noise in the workers. Mod-installed input axes can no longer silently swallow built-in aim/movement input (ledger #426, app 808a397d).

A mod that declared one of the engine's built-in axis names (aimYawSin, aimYawCos, aimPitchN, the camPos*/pointer* camera axes, lookX/lookY) got that name namespaced on install — odm-player:aimYawSin — and from then on every read of input.axes.aimYawSin from the mod's own scripts resolved to that namespaced key. Nothing can ever feed it (the engine writes only the bare names into input frames), so the read returned 0 forever on both realms, silently shadowing the live engine value: aim collapsed to {0,0,0}, movement and aim-driven mechanics died, and no fault or DM fired because the key was technically defined.

Two pieces:

  • Spec ingestion now strips namespaced aliases of engine built-in axes ({mod}:aimYawSin and friends) on every apply, on both realms. Existing games that already carry these axes heal the moment they load this engine: the mod script's bare read falls through to the engine-injected value and just works, Savi gets one runtime log + DM naming the removed keys and the read-it-directly pattern, and the next spec edit persists the repaired inputs. Bare declarations and mods' own legitimate axes are untouched.
  • Mod publish rejects engine built-in axis names in mod.json inputs outright, with the same guidance — the dead declaration can no longer enter the registry.

The reserved list is shared (ENGINE_INTERNAL_AXES in @spawn/tome-schemas) and pinned to the engine's actual injection set by test, so the proxy, the ingestion repair, and mod publish can never disagree about which names are the engine's.

  • physics: false is now a valid physics value everywhere (ledger #479): the spawn/property validator (PROPERTY_VALIDATORS.physics), the Zod spec schema (PhysicsSpecSchema), and the ObjectPhysics type all accept it as "no physics body" — the value the engine ITSELF defaults primitive children of simulated dynamic/vehicle parents to since #6685 (resolvePrimitivePhysics). Pre-fix, the first p.box(...) child of a vehicle chassis threw spawn(): physics: expected { body: ... }, aborting the whole onSpawn after visible: false had landed on the parent — the invisible car. The #6685 default itself is unchanged (a static collider nailed inside a simulated chassis is hit by the parent's own contacts and suspension rays).

  • Behavior hook errors now reach run_script's returned logs: reportBehaviorError records its runtime log BEFORE the DM-notifier gate. Exec-worker worlds have no DM notifier (live-channel resource, absent in the worker), so a hook error during a transactional exec previously vanished into server stdout while run_script returned ok with no logs — the swallow that hid #479.

  • The model-loading placeholder no longer renders its two translucent sphere shells (ModelPlaceholder:bubble glass fresnel + ModelPlaceholder:spinner aurora) — the billboard hologram plane (ModelPlaceholder:preview) is the whole presentation (three/extensions/models/placeholder-visuals.ts, ledger 417). Both sphere InstancedMeshes, their TSL materials, and the failedFactor attribute (the shells' amber failed-state tint) are deleted; the failed state still persists and still reads via the "Couldn't spawn …" label, since the placeholder derivation in three/models.ts is untouched. The shells had no non-visual role (no raycast/cull/bounds duty; fade/scale animation lives in animState and drives the plane), so behavior is otherwise identical — the shells' castShadow blob disappears with them.

  • Hologram instances are now depth-sorted: the per-frame instance writes go back-to-front by view depth along the camera forward axis (all holograms are screen-aligned billboards in one transparent depthWrite-off InstancedMesh, so buffer order is blend order). Previously overlapping holograms layered by collect order, so a near hologram could blend underneath a far one.

  • New run_script diagnostics (ledgers #439/#440): api.getClientHealth() returns per-client device health — a quality block (adaptive-quality ladder rung + bottom rung, deepest held row id, renderScale, bloom/half-rate state, opening provenance prior/landing/rung-force, last 8 rung transitions with triggers and crossed row ids, and the frame-budget guard's CPU-vs-GPU attribution) and a prediction block (30s mismatch window with drift/push/skew classification, corrections/s, resim ms/s, baseline-adopt rate, and the top 5 mismatching components each with one srv/cli sample pair). Clients report a compact validated snapshot every ~15s (plus a throttled edge report on rung transitions) over the existing cmd.* command channel (engine.clientHealth); the server mirrors the newest snapshot per authenticated clientId. In multiplayer the read answers for every reporting client (one entry per connected player, joined with display names); in singleplayer the forwarded run_script reads the client authority's own locally recorded snapshot.

  • New api.resetQualityLanding(clientId?) (consent-gated: Savi proposes, the creator approves): pushes a quality.landing.reset control to connected clients (all, or one). The receiving renderer releases every held quality rung immediately through the normal knob reconcile, bypasses the boot-cut release meter for the session (AdaptiveQualityLandingRecorder.noteExplicitReset), and persists the neutral landing tombstone — clearing the cross-session landing. Quality-only and reversible: a genuinely overloaded device re-earns its descent from fresh measurements through the armed early-evaluation window (QualityGovernor.resetLanding). Construction-frozen cuts (MSAA, lighting tier, terrain PBR, IK) restore on the device's next reload.

  • The renderer perf sample's governor block now carries rungId, topReachableRung, opening provenance, the guard's fused signal, and per-transition crossed row ids — consumed by the client-health report only (the DD rollup hop copies fields explicitly and forwards none of them).

  • The adaptive-quality-step boot entry and the render/client-sim perf pointer DMs now name getClientHealth() (and the boot entry, resetQualityLanding()), so a Savi looking at a blurry-session log can reach the read and the consent-gated reset directly.

  • Debugging skill: new "Is It the Game or the Device?" section routing the two symptom families (blurry game → quality block; rubber-banding/warps/"CPU lag" → prediction block, where a per-tick replicated value reads as a push storm).

  • Fixed the singleplayer dual-streamer fight (ledger #286): singleplayer glue runs every server-only system in the client world deduplicated by system NAME, so terrain/chunk-streaming (server) and terrain/client-streaming both survived and fought over the shared terrain/stream/<place>/<key> entity namespace with disagreeing desired sets (client extended desktop bands ~2,600 chunks vs server standard ~700). reconcileExistingChunks made the server pass adopt the client's far-band chunks, evictUndesiredChunks despawned them after the 30-tick keep-alive, the client respawned them and queued fresh builds — re-fired on every player chunk-coord change, a ~1,900-chunk rebuild/evict annulus per second while moving (5 fps; standing still was stable).

  • In a singleplayer world ONE streamer now owns the namespace: the server streaming pass suppresses itself (isSoleClientStreamerWorld — client-mode world + singleplayer spec), and the client pass absorbs the server pass's single unique responsibility, the guaranteed LOD0 chunk set under physics anchors (dynamic bodies / character controllers / awake vehicles always get resident chunks + colliders, derived with the same gatherers and AOI resolution the server pass uses). The guard is client-world-scoped: the room container's server-mode world for a singleplayer spec keeps its streamer, and real multiplayer worlds are untouched.

  • Removed the server streaming pass's write-only ran-tick bookkeeping (markServerStreamingRanTick — no readers anywhere) and documented the glue dedup loophole at the seam so the next differently-named server/client system pair decides namespace ownership explicitly.

  • Sprite frame/time are render-clock state — moved off the replicated component entirely (ledger #225). The sprite animation system is mode:"client" (interpolation phase): it used to advance draw/sprite frame/time (and mixer-selected texture and auto-facing flipX) every render frame while the server never animates sprites, so the authoritative value kept frame=undefined / time=0 forever. The detector compared the shared+aoi component bit-exact and booked that by-design divergence as drift every tick on every animated sprite (PROD: rabbit-meadow-N ×22t, sprite.frame srv=undef cli=varies) — a resimulation per tick in a trivially simple world, the resim cap, and a baseline-adoption teleport on predicted 2D players. Fix is structural: all resolved playback state now rides the client-plane draw/sprite-resolved overlay (replicate:"never" — invisible to both replication and the mismatch compare set), and the renderer composes resolved over authored per field. An interim 5.0.3-cut fix declared correction: { mode: "snap" } on draw/sprite (the #6673 cosmetic-mispredict vocabulary); the overlay supersedes it and the declaration is removed — unlike draw/mixer weights and tween clocks, draw/sprite has no legitimate per-realm divergence left (every writer is realm-deterministic or the server-only metadata size upgrade, which classifies as PUSH for replay-free adoption), so it stays in the compare set at full fidelity and a divergence there is a genuine bug again. No wire change; the taught surface is unchanged (setProperty("sprite.frame", 0) and friends run authored on both realms; the resolved overlay is engine-internal). The sprite clock is rollback-immune by construction: playback state lives outside the ECS and the overlay is client-plane, so rollbacks never touch either — pinned by test alongside a two-world rabbit harness (zero mismatches booked while the resolved clock genuinely runs; a real position glitch on the same entities still resims at full fidelity).

  • Tilemap tileset taps are array-safe (ledger #487): TilemapNodeMaterial.buildColorNode sampled the tileset with a plain .sample(tileUv) and setMap assumed tilesets are never CompressedArrayTexture. The tileset field accepts any texture URL, and the KTX2 transport delivers a layered array for animated atlases — binding one produced the three-arg textureLoad(texture_2d_array<f32>, vec2<u32>, u32) (tilemaps force nearest sampling, so every tap lowers to textureLoad), which matches no WGSL overload. The pipeline never compiled and the invalid pipeline poisoned every command buffer it was bound into — the entire frame went black, not just the tilemap. setMap now mirrors SpriteNodeMaterial.setMap's graph-shape flip: an array tileset rebuilds the graph with .sample(tileUv).depth(0), so a mis-pointed tileset renders its first frame instead of killing the game.

  • WGSL pin for the whole sampler2DArray tap law (engine/materials/__tests__/texture-array-tap-wgsl.test.ts): builds the tilemap (unlit, lit, linear, plain-2D) and sprite (batched + standalone, pixel-filtered frame array — the animated pixel-avatar config every 2D game ships) materials through the backend's real WGSLNodeBuilder and asserts every textureLoad on a texture_2d_array binding carries exactly four args (and every textureSample* at least four), non-vacuously. This is the regression guard for the class of failure behind the 2D staging blackout: the crashing sessions ran 5.0.9 engine bytes minted before the 2D reland's array-aware sprite taps, so the cure for live games is the next engine version carrying master — this pin keeps the class dead on every future mint.

  • GPU validation diagnostics now reach observability (the blackout's root cause lived only in the player's devtools): the renderer worker's console never reaches the page, so rejected-pipeline messages were invisible to the iframe→parent error forwarding. The renderer host re-emits the renderer-pipeline-failed and renderer-gpu-recovery diagnostic messages on the page console (first 5 per session), where the existing forwarding carries them into Datadog with origin: iframe. The diagnostics rail to Savi is unchanged. Parked vehicles now sleep, ending the parked-car resync storm (ledger #416). updateVehicle ran every substep for every vehicle controller and reset the chassis sleep timer, so a parked, driverless car could never sleep — it idled awake forever and any micro excitation (suspension settle, terrain crown, brake-free rolling) became a permanent limit cycle in its replicated lanes. Under prediction that cycle ran in a different phase on client and server (vehicleSpeed ±0.045 in anti-phase in the #416 dumps: each side under the 0.05 velocity epsilon, the pair past it), so the comparator booked a full rollback+replay on most ticks — 18k replayed ticks and 363ms/s of resim in the capture, felt as rubber-banding, eaten inputs, and lag near any parked car. The engine now rest-detects vehicles (dynamic chassis, no held throttle, velocities under thresholds for 0.5s), freezes them at exact zeros (vehicleSpeed: 0 on the wire) and puts them to sleep; stepPhysics skips updateVehicle for sleeping chassis. Sleep rides the wire authoritatively, both sides converge on identical frozen rows, and every existing wake path (throttle/control-lane change, tuning writes, authoritative moves, collisions) resumes simulation. Parked cars also stop paying per-substep suspension raycasts.

  • Behavior budget watchdog no longer bills frame stalls to scripts (ledger #443). The watchdog samples wall clock around each entity update(), and wall clock measures the thread, not the script: a main-thread stall (an additive-FX burst frame, spec-apply asset installs, GPU pipeline compiles, GC storms) followed by the fixed-step ticker's back-to-back catch-up burst could collapse "3 strictly consecutive over-budget ticks" into one contended wall-clock window and park trivial scripts for 10s — several independent ones simultaneously, since the stall contaminated every bracket in the window. Singleplayer was the exposed realm (the client is the authority, ANY entity may park there, and the sim shares the browser main thread with rendering and loads). Two guards in behavior-watchdog.ts: (1) stall grace — beginBehaviorBudgetPass watches the wall gap between behavior passes (scripts cannot create that gap; their time lands inside the pass), and a gap past max(150ms, 3 tick intervals) marks the catch-up burst it mints (≤8 ticks) as sampled-but-never-striking; (2) strike separation — strikes for one scope must be at least a full tick interval of wall clock apart, so back-to-back burst ticks inside one stalled window count as one observation, not N offenses. A genuinely heavy update is unaffected on both counts: its time lands inside the pass (no gap) and its own bracket is the separation, so the 3-consecutive-ticks runaway net is unchanged at steady cadence. Grace state deliberately survives clearBehaviorBudget — a spec apply is itself a stall source, and the grace it earns must cover the post-apply burst. Physics-paused stretches (terrain loading) now also read as one large gap on resume, so the first running ticks after an install grind can't park managers.

  • The authored sprite is the player's visual in 2D places (ledger #509): the 3D starter's player.js re-wears the profile avatar in onSpawn (setProperty('model', profile.avatarUrl + '?animations=…')), and when a game was converted to 2D with that script still in the behavior list, the model setter's 2D branch skipped its sprite conversion for entities that already wear a DrawSprite and fell through to writeDrawModel — stacking the rigged 3D avatar on top of the authored sprite for every joiner with a profile character, in every 2D sprite game. The rule, applied at all four decision doors: in a 2d-side/2d-top place an entity with a sprite keeps the sprite — the model property setter ignores model writes, the spec dispatch (applyAppearanceProps) sheds the model instead of writing it, and the animated3DCharacter: true profile-avatar resolution (configure-at-join, re-spec, and property setter) does not inject. Decisions are pure functions of replicated inputs (spec place mode + sprite), so both realms agree. 3D places and sprite-less 2D entities are byte-identical to before — .png.glb implicit sprite conversion and true-3D models in 2.5D scenes still work.

  • Pinned in tome/__tests__/player-sprite-2d-avatar.test.ts (the live game's exact starter-script shape, both modes, re-spec, non-player sprite objects, and the unchanged 3D/conversion paths).

  • Ledger #510 adjudication probe (tome/__tests__/probe-2d-coin-trigger.test.ts): the 2D trigger pickup contract works end to end — a static trigger-sphere coin against a string-shorthand (physics: "character") sprite player in 2d-side delivers onTriggerEnter, the taught other.tags.includes("player") check collects, and the probe pins that the trigger payload has no other.isPlayer field (the live game's coins gated on it — authoring bug, not an engine gap).

  • builtin/combat's damage() no longer writes the dead flag into the victim's state (ledger #489, found in goblinjo's "Genre Flip"). The killing blow used to patch { health: 0, dead: true } atomically — which made the canonical taught death branch (if (s.health <= 0 && !s.dead) { ... patchState({ dead: true }); runInSeconds(respawn) }) unreachable on the victim: by the time its update ran, dead was already true, so no death announce, no death FX, and — critically — no respawn timer was ever armed. Any game combining the two taught patterns (self-managed player death + enemies attacking via combat.damage) permanently bricked the player on first death, in both singleplayer and multiplayer. Death semantics now belong to the victim's script, mirroring heal()'s documented "revival is a game decision".

  • damage() still no-ops on dead targets: readHealth derives dead from state.dead === true || health <= 0, so a zero-health corpse whose script never writes a dead flag cannot be hurt-event-spammed. Non-lethal hits also no longer write dead: false, so a script-authored dead: true at positive health survives stray damage calls.

  • The returned CombatHealthSnapshot.dead still reports the kill (health <= 0), and isDead() is unchanged.

  • Teaching surfaces updated to state the contract explicitly: skills/combat.md (best practices), skills/npc.md (builtin/combat section), and the @tomeapi-example enemy-AI comment in tome/_examples/game-manager-examples.ts (regenerated docs/TomeAPI.md + skill example blocks).

  • Fixed CDN-model colliders (convexHull/trimesh from GLB cooks) silently missing on one side in multiplayer: cook signatures hash the asset-manifest signature, so manifest skew between server and client made byte-identical cooks unmatchable by signature and the prop had NO collider in the client prediction world — anyone standing on it free-fell every predicted tick and got yanked back by corrections (ledger #503, the staging dock mispredict storm: 133 mispredicted ticks/30s). The asset-identity fallback (resolveColliderMeshByAsset, "converged geometry is the invariant") existed but was wired only into mantle; it now lives in the shared rapier resolver (resolveColliderMesh), so isMeshColliderReady and static body realization converge on geometry across manifest skew on the default engine too.

  • Root cause of the "way too plasticky" default terrain (ledger #501, with receipts): the Patina NRO roughness channel is far darker on real ground textures than the ledger #22 de-shine assumed. The stock terrain pack measures grass at median 0.22 (p10 0.067), gravel 0.33, rock 0.17 — the ×1.4 default multiply lifts the grass median only to 0.31, so the renderer's 0.4 floor clamps it. Most of the default terrain rendered a FLAT 0.4 semi-gloss — glossier than the flat 0.55 sheet ledger #22 originally complained about, because that fix also lowered the floor 0.55 → 0.4 on the assumption the bias would lift typical samples past 0.39. The lighting stack is not the culprit: the material pipeline and floor are unchanged since the #22 fix; the default was simply never matte.

  • A multiplier can't fix maps this dark inside the knob's 0-2 range (×2 leaves the grass median at 0.44), so the roughnessIntensity curve above 1 changes from multiply to a matte-lerp: mix(sample, 1, intensity − 1) (patinaRoughnessNode, terrain-layer-atlas, both top and biplanar side projections). Below 1 the multiply is untouched (polish direction, identical behavior); the curve is continuous at 1; one branchless TSL expression. No clamp band ever flattens the map — its full spatial structure survives at any matte level.

  • PATINA_DEFAULT_ROUGHNESS_INTENSITY 1.4 → 1.7: the stock grass renders ~0.72–0.85 effective roughness (gravel ~0.78–0.84, rock ~0.71–0.90) — dry natural ground with visible variation, glossiest possible default texel 0.7. TERRAIN_ROUGHNESS_FLOOR stays 0.4 — at the new default it never engages; it remains the mirror-gloss guard for authored polish.

  • Pins: PATINA_DEFAULT_ROUGHNESS_INTENSITY = 1.7 (factory.test.ts, parameter level) and the WGSL curve on both projections (terrain-biplanar.test.ts, codegen level).

  • The asset-loading hologram now sits on the surface where the model will land: the billboard plane's bottom edge is pinned at the entity's feet Y (position.pos[1] — the same transform/world-feet-position anchor the ground-aligned model attaches under) instead of centering the plane at the predicted volume's mid-height, which left 0.125 × predictedHeight of air under the disc (three/extensions/models/placeholder-visuals.ts, ledger 497). Before preview metadata resolves, the conservative entity-scale volume is grounded the same way; when expectedMetersHeight arrives the disc grows upward from the ground toward 0.75 × predicted height with the bottom edge pinned every frame. Slope overlap is cropped by the existing depth test against terrain.

  • Built-in cameras follow the control target's scale (ledger #506, "The Button That Presses Back"): first-person.eyeHeight and third-person.heightOffset/distance/lookOffsetY are now measured at body scale 1 and multiplied by the target's effective scale (WorldScale × GeometryScale — the same fold the physics collider build uses, all replicated, so every realm derives the same framing). A shrink button that scales the player now drops the eye/orbit with the body instead of leaving the camera at full height staring into the wall above a crawl door the (correctly resized) capsule walks through. The third-person occlusion probe (cast radius, back-off, pull-in floor) scales with the body too, so a tiny player's camera can follow into tight spaces. Zoom state stays in scale-1 units (a shrink doesn't reset zoom); custom camera scripts are untouched — fully authored — and get getControlTarget().scale to do the same multiply themselves (camera skills + examples now teach it).

  • Object-valued physics.collider is loudly coerced (the session's silent killer-adjacent): scripts keep writing collider: { kind: "capsule", radius, halfHeight } expecting to resize a character's capsule — the dims were silently ignored (the shape derives from the primitive/model), which reads as "physics doesn't follow my resize" and burns retry loops. The named kind is now honored, and a once-per-entity runtime warning (console + getLogs rail) teaches the working primitive: colliders size from the primitive/model and follow the entity's scale property automatically.

  • Adjudication: the reported physics half of #506 is ENGINE-CLEAN and now pinned — tome/__tests__/player-scale-capsule.test.ts proves a scripted scale write rebuilds the rapier character capsule (dims + feetOffset) and walks a 0.32-scaled player through a wall-hole door that blocks the full-size body (the live game's exact gate shape); engine/physics/__tests__/mantle-player-scale.test.ts pins the mantle leg (scaleRef-moved rebuild, signature folds the quantized scale). Camera scaling pinned failing-test-first in tome/__tests__/camera-scale.test.ts.

  • Default decoration card size raised ~50%: decorationCardLayout height default [0.3, 0.5] → [0.45, 0.75] (ledger #502 — Jacob, tasting 5.0.10: Savi's scatter/decoration output reads sparse and miniature; default-size carpets were the size anchor). Doc comment on DecorationItemSprite.height updated to match.

  • Scatter/decoration density+size teaching raised ~50% across the curriculum, per the prompt-craft doctrine (raise the examples, don't add rules):

    • _examples/behaviors.ts scatter examples — forest core 140→210 (poisson 7m→6m so the count actually fits), edges 20→30 (15m→10m), undergrowth 250→375 + scale [0.6,1.0]→[0.9,1.5], flower garden 150→225 + scale [0.8,1.0]→[1.2,1.5]. Wheat field's count: 2400 lied against the 500/bed cap — now an honest 495 at 1.1m grid spacing with the cap named.
    • skills/heightmap-terrain.md decoration taste — density bands 0.1–0.5/1–3/4–8 → 0.2–0.8/1.5–4.5/6–12, taught budget 250k → 300k (the engine's actual DEFAULT_BUDGET ceiling; it was under-taught).
    • skills/world-composition.md Enchanted Forest recipe — ~120 canopy/200 undergrowth/400 cover → ~180/300/500, with the per-bed cap named in-line.
  • The SCATTER_CHILD_LIMIT = 500 clamp (ledger 191/259) is now documented at the source: ScatterSpec.count JSDoc names the cap and the denser-carpet escape hatches (multiple beds, decoration layers), so the generated contract every skill carries stops letting taught counts silently thin. All raised example counts stay at or under 500/bed on purpose — raising the clamp itself waits on the impostor follow-up (renaud's PR #6321, the open octahedral bake pipeline stacked on merged #6319 — master-shaped plan in the ledger artifacts; supersedes the earlier #5612-based note) which makes far-field density nearly free before the cap moves.

  • Ledger #502 aim correction (Jacob: "decorations is the big one — scatter !== decorations"): the density half of the ask lands on the terrain-decoration system as teaching, per the 502 ruling (taste lives in skills; defaults are Jacob's call).

    • Honest-clamp teaching (the dishonest band corrected): heightmap-terrain.md density bullet drops the unreachable "6–12 dense" band (and trims "1.5–4.5" to a reachable "1.5–3") and now teaches the real mechanism — the ask is density × π·maxDistance² split across items by weight, each sprite/primitive item caps at 50,000 instances (~density 0.7 per item at the taught 150m maxDistance), denser carpets come from 3–4 item variants per layer. Same contract documented at the source on DecorationLayer.density (decorations-types.ts JSDoc) and on the DEFAULT_ITEM_CAP constant.
    • #6319 ring-falloff verdict (measured, default 80m chunks / 200m far): the near field is NOT thinned — ring fractions are 0.41/0.40/0.19 (near/mid/far), giving the 0–80m ring the highest per-area density (~0.79/m² for a 50k item vs 0.20 mid / 0.06 far ≈ the intended 1/r² screen-uniform ladder), and the GPU EXP keep-curve is ≥0.99 near, 0.94 worst-case at 200m. The felt sparseness is cap- and density-bound, not curve-bound. Residual finding: ~39% of every item's allocated instances are dead cells (each LOD ring places its count on a square [−r,+r]² lattice and zero-scales cells outside the annulus — the far ring wastes 72%); fixing that is placement-domain surgery (the world-snapped blue-noise lattice is load-bearing), named as a follow-up next to the impostor work.
    • PROPOSED (not shipped — Jacob's call; he chose the item-size lever instead, 2026-06-10): raise DEFAULT_ITEM_CAP 50k → 75k. It is the +50% felt-density lever for cap-bound carpets (most single-texture grass layers): one sprite item saturates at ~0.4/m² over the default 200m disc while the curriculum asks for multiples of that, and the prop-era cap predates sprite cards becoming the modern grass (deprecated grass rides the full 300k budget). The 300k budget would stay the global ceiling (allocateDecorationBudget trims past it), so worst-case engine instance count would not change. One constant + one pinned test + the three doc sites.
  • Regenerated: docs/TomeAPI.md, skill generated-example/type blocks, skills manifest.

  • Terrain layer atlas no longer races on download order (ledger #514): the compressed albedo/NRO arrays used to template themselves off whichever KTX2 slice finished downloading first — its size/format/mip-count became the atlas contract and every later-arriving texture that didn't match was rejected to tint-only fallback. Stock packs mix sizes (grass Patina variants 1024², gravel/rock 512²), so which materials rendered textured was a per-session coin flip; worst case the whole pack flat-tinted until a lucky reload. The arrays now converge deterministically on the pack's smallest source size in every order: full block-compressed mip chains nest bit-exactly, so a larger source joins by dropping leading mips (no transcoding), and a smaller late arrival re-templates the live array down — one CPU mip reslice that reuses the existing buffers plus one async GPU re-upload at arrival time, never per-frame, with the existing revision bump driving the pool-material rebuild exactly like the placeholder→array swap. The retired array stays alive until atlas dispose because live materials may still bind it until they rebuild.

  • The "can't be packed into the terrain layer atlas" warning is now unreachable for well-formed packs and strictly per-material when it does fire: only a source that genuinely can't share the array (uncompressed, different transcode format/colorSpace, or a truncated mip chain that doesn't nest) keeps its own layer on tint — the rest of the pack streams normally.

  • Determinism pin in engine/renderer/__tests__/terrain-layer-atlas.test.ts: feeds a mixed-size pack in both orders (small-first and large-first) and asserts every material lands textured with each source's own texels and that the settled atlas contract (size, mip count, format) is identical across orders; plus re-template-keeps-resident-layers, retired-array lifetime, per-material truncated-chain fallback, and NRO-array convergence.

  • CompressedArrayTextureManager grew updateLayerFromTemplate (write a pre-normalized mip chain) and shrinkToTemplate (re-template to a mip-suffix, resident layers carried over verbatim, retired texture returned to the caller); texture-array-utils grew sliceTemplateToSize + fitCompressedSourceTemplate. Other consumers (primitive texture arrays, placeholder previews) are behavior-unchanged.

  • Ledger #500 (engine half): a tilemap whose tileset never resolves no longer renders the whole ground as an invisible void. The arena 2d-dungeon's blankFrame was every /cdn/ texture failing (cold magic-cdn variants — the .ktx2/?normals=auto transcodes were ungenerated, and the unauthenticated capture browser cannot trigger generation, so kiln 401s); sprites already went LOUD for that window (loading rings / plate wash), but the tilemap's missing-tileset fallback was a 1×1 transparent texture whose every texel discards — silent black, zero page/GPU errors. TilemapNodeMaterial now carries a second missing-map presentation, "cooking": a 2×2 opaque neutral-gray checker (the ground's quiet wash). syncTilemapLoadingPresentation (per-frame, before the transition veil composes) flips a record to cooking once its tileset load outlives the sprite anti-flash window (spriteLoadingPlaceholderRevealed, same 2s pending-clock rule) and back to art the frame the texture lands. Tileset swaps reset to the anti-flash hidden state, so live-edit rebinds never flash the wash. The wash stays on the place's lit/unlit graph (scene lights carve pools on it) but self-illuminates while showing — SpriteLightingModel's emissive slot driven by a uniform that is 1 only while the wash is actually bound — because loading scaffold must read in a dark-authored scene exactly like sprite loading rings do (a 0.55 dim-ambient dungeon renders the unlit wash at ~1% luminance; the void would be back). Both flips are uniform/texture-binding swaps, never a shader rebuild.

  • getTextureState now reports "failed" between retry attempts (getModelState parity) — a failed texture previously read as "pending", indistinguishable from never-requested. Healing is unchanged: retryExpiredFailedAssets keeps re-asking while a subscriber waits (verified: a cold variant that fails N times then cooks notifies the still-armed subscriber and the tilemap heals live, no reload — pinned in renderer-asset-texture-state.test.ts).

  • Session Lab: new screenshot_min_luma scenario assertion (kernel-direct + wrapper-embed) — a named capture's frame must carry a minimum meanLuma. This is the regression seam for "renders black with zero errors" failures (487/500 class). ledger-500-tilemap-lit-local pins the lit-2D tilemap pixel floors (authored-dim ambient, white ambient, zero-light fallback guarantee, unlit); ledger-500-tilemap-cdn401 pins never-silent-black under a permanently failing tileset.

  • Ledger #507 (same cold-variant family, jobs half): job errors can now declare themselves TRANSIENT. TransientJobError (typedefs) carries retriable: true; the worker harness honors the marker instead of inferring retriability by sniffing the message for "network"/"timeout" (a staging 429 burst marked glb-bounds terminally failed — retriable: false — and the model silently never got bounds for the life of the room). glb-bounds classifies its fetch: 429/408/425/5xx and raw network rejections are transient (retried in-worker with backoff against the submit's new retries: 3 budget, well inside the 10s deadline); genuine 404/401/403 on the authenticated server path stays terminal and rides the existing ~30s feature-level resubmit. Pinned: first N fetches 429 then 200 → bounds extracted, job never terminally failed (glb-bounds-job.test.ts).

  • Ambient loudness teaching (ledger #512, "background noises consistently too loud"): every layer that taught Savi ambience either modeled no volume or blessed full scale — playSound/ambience API docs carried zero loudness guidance, the always-on AudioSpec line said gain (usually ≤1) (gain 1 passes), and the skills that order "2–3 ambience anchors" (world-composition, heightmap-terrain) never said how loud. With playSound({ loop: true }) defaulting to volume 1.0 on the SFX bus (vs the engine's own ambience channel at 0.5 × 0.8 Ambience bus = 0.4 effective), her beds came out ~2.5× the engine's intended ambient level. The fix is a loudness hierarchy with real numbers at the layers she reads: <sound> identity block (beds loop at ~0.25, felt not heard), playSound/ambience doc strings (one-shots 0.4–0.8, loops 0.15–0.3; ambience default 0.5, 0.2–0.35 reads as place-tone), the AudioSpec schema line (gain (ambient loops ~0.3, one-shots ≤1)), and the two anchor-teaching skills now show the calls with volumes in them. No engine behavior changed; existing worlds sound identical. Ships to Savi via the generated skills manifest + prompt at the next chat deploy.

  • Parked for Jacob (engine-default changes that would alter existing worlds — proposal only, not shipped): (1) a quieter default volume for looping playSound (loops are beds in practice; a 0.3 loop default with one-shots staying at 1 matches the taught hierarchy), and/or routing positionless loops to the Ambience bus instead of SFX; (2) gapless looping for mp3 beds — looped voices play the raw decoded buffer (source.loop = true) and mp3 encoder delay/padding puts an audible seam click at every cycle, which is the "grating" half of the report; trimming near-silence at loop points or an equal-power crossfade would fix it but subtly changes all existing loops.

  • Renderer resource gauges ride the perf rail into debug dumps (ledger #504). The renderer worker's 1s RendererPerfSample now carries a resources block — programs (compiled shader-program cache size, the material/pipeline-cache growth meter), renderTargets, geometries, textures — copied O(1) off the already-maintained renderer.info.memory counters at sample time (zero per-frame cost; absent on backends that don't report them). The sim worker's 15s rollup folds it last-wins into payload.renderer.resources.

  • kiln (rides the same merge): the dump capture retains the full renderer counters block off every rollup that carries one (RetainedRendererCounters in core/kernel/session-captures.ts — drawCalls, triangles, cpu/gpu frame ms, buffer/texture MB, plus the new resource gauges; validated and bounded like every #284 capture) and the debug-dump diagnostics gain a renderer block + a === Renderer Counters === text section. The #504 staging investigation was dump-blind exactly here: "grows with edits, resets on reload" could not name the growing resource because dumps carried no renderer counters. Two dumps from one session now answer it directly — programs/renderTargets/textures climbing across edits = accumulation; flat caches with a high drawCalls/triangles floor = the scene is just expensive.

  • The DD perf-rollup route is unaffected by construction: its renderer zod object is non-strict, so the new resources key strips at the telemetry hop (dump-only, same lane as hitches.worstRing).

  • api.getSpec() inside room exec no longer answers with the boot spec forever (ledger #519, P1). Root cause was NOT the #376 exec-worker snapshot lane (the WorldSnapshot/blob-cache path faithfully ships the live GameSpecResource, proven by pins for setScript/spawn) — it was the mutation-persistence seam: setObjectProperty writes live components and records a setProperty mutation for kiln's DB fold, but nothing ever folded that mutation back into the room's live spec. The room's own persist echo is deliberately recognized and skipped (#326/#352), so the live document stayed at boot for the whole session, and every read-modify-write Savi ran through exec (read getSpec → tweak → write back, e.g. the g504 scatter respawns) based on stale data and silently clobbered earlier edits.

  • Fix: setObjectProperty (and the dotted-key path) now has its member of the in-memory spec-mirror family — mirrorPersistentSetPropertyInSpec, beside the existing mirrorPersistentSpawnInSpec/mirrorPersistentDestroyInSpec (whose doc comment claimed setProperty was already covered; it only was for god-mode's updateObjectSpec). The mirror folds the recorded mutation into the live spec with exactly kiln's fold semantics (tags/behavior/parent top-level, parent: null deletes, everything else under properties, objects with no authored row skipped — kiln skips those mutations too), gated exactly like the recorder: tracker enabled, not suppressed, persistable id. Behavior-script property writes (tracker off) pay three resource reads and nothing else.

  • Recorder-parity suppress gate added to mirrorPersistentSpawnInSpec/mirrorPersistentDestroyInSpec: scatter/field-bed children spawn under SuppressMutationRecordingResource via api.spawn, recorded no mutation, but WERE mirrored — every persist-mode scatter write grew phantom …/scatter/… rows in the in-memory document that the DB never had. The mirror family now fires iff the recorder fires.

  • Both exec realms agree: in the server room the fold rides the exec worker's TransactionLog (lww spec patches behind the #376 spec gate — staged writes coalesce to one whole-spec value per exec on the wire); on the singleplayer client authority the fold bases on readAuthoritativeSpec (whole-world view, ledger #185) and refreshes tome/unfiltered-spec, so place-filtered worlds neither hollow other places nor lose the next RMW base.

  • Pins in src/tome/exec/__tests__/exec-spec-read-across-execs.test.ts: cross-exec getSpec visibility for setScript / setObjectProperty / spawn, compounding RMW (the exact g504 scatter shape, two generations), no phantom scatter-child spec rows, and the singleplayer place-filtered realm.

  • Ledger #520 (P1): the kernel agent docs taught terrain authoring as heightAt(x, z) while the engine contract has been heightAt(ctx) since the TerrainConfig API was born (#4095, Nov 2025 — the positional shape was NEVER the contract). Savi wrote the documented arity in arena racing-3d, every height sample threw a TypeError, and the engine silently swapped in seeded fallback mountains spanning [-32, 256]; her flatten mark then carved the 150m pit Jacob spawned in. Three-part fix:

  • Docs: apps/cf-kernel/SPAWN_AGENT.md (and generated CLAUDE.md/AGENTS.md) now say heightAt(ctx)/materialAt(ctx). That was the only repo surface teaching the wrong arity — the skills corpus, tome examples, and kiln agent-api docs were already correct or silent.

  • Arity adapter (intuitive-by-default over teaching rules): both generator compile paths (engine/features/terrain/generator-runtime.ts worker runtime and tome's compileTerrainGenerator, via one shared adaptPositionalGeneratorExports) detect the positional shape at compile (fn.length >= 2 — two or more required parameters can never be the single-ctx contract; default/rest params don't count toward length) and adapt the call: fn(ctx.x, ctx.z, ctx) (tileAt: fn(ctx.x, ctx.y, ctx)). Both shapes simply work. Pure argument re-routing — deterministic and identical on server and client, so generator parity is unaffected; the spec-apply probe exercises exactly what the chunk workers run. Chosen over a loud author-time error because the intent of the positional shape is unambiguous and the kinder fix removes the failure class instead of narrating it.

  • No manufactured mountains: a faulted AUTHORED generator now renders FLAT (0 clamped into the vertical range), never the procedural-noise fallback. Closed in both samplers: heightFromDefinition's per-sample catch and resolveHeightmapBaseSampler in jobs/chunk-build.ts (the path that built racing-3d's chunks), plus the compile-failed-module case (generatorSource present, module null), which previously fell into the "no module" procedural pathway. Per-sample faults report once per content version per function through the existing engine-diagnostic rail (terrain-generator-runtime-error — allowlisted since #369 but never emitted until now); compile failures keep their terrain-generator-compile-failed report, with the message corrected from "falls back to procedural noise" to "renders flat". The procedural fallback survives only for definitions with no generator source at all (deliberate pathway) and the engine default terrain (whose baked procedural module is healthy). New broken-generator-flat height pathway in the observability counts.

  • Note for the next cut: the unmerged ledger-480-terrain-resnap branch (5c2c5bada) carries an overlapping fix in heightFromDefinition (flat-on-throw) plus a probe arity-REJECTION. This changeset's adapter supersedes the arity rejection (the shape now works — the probe must not reject it); the flat-on-throw hunks will conflict trivially and resolve to this branch's version, which also covers the compile-failure and chunk-build paths #480 didn't.

  • Tests: engine/features/terrain/__tests__/generator-arity.test.ts (both positional shapes incl. the racing-3d one, ctx helpers through the third argument, materialAt/tileAt, single-ctx untouched, end-to-end through heightFromDefinition), engine/features/terrain/__tests__/broken-generator-flat.test.ts (flat-not-noise for throw/non-finite/compile-broken, vertical-range clamp, no-source procedural pathway pinned unchanged, chunk-build sampler flat, runtime-error diagnostic once per content version), tome/__tests__/terrain-generator-faults.test.ts (positional spec applies silently and resolves authored heights).

  • Multiplayer: opening the same game in a second tab/window of the same account no longer leaves the replaced tab a zombie (ledger #513). The server now sends a session.superseded control frame and closes the replaced LIVE socket with SESSION_SUPERSEDED_CLOSE_CODE (4431) the moment a second connection attaches with the same clientId — both on a Ready replacement (replaceReadyConnection) and on the boot-race variant (a second attach landing while the first join is in flight). Previously the old socket stayed open while the ingress guard silently discarded every input frame it sent: its client kept predicting optimistically (buys/clicks applied locally, then rubber-banded away on the eventual reconnect snapshot), and its auto-reconnect stole the session back — the two tabs ping-ponged each other's input dead. The client treats the superseded close as terminal (no auto-reconnect; reconnecting would steal the session straight back) and shows an honest "opened in another tab" message; taking over again is a deliberate page reload. Grace-window reattaches (flaky network, same tab) are unaffected — the old socket is already dead there and nothing is sent. Ledger #511 (Village Meadow dark-spots): games with a procedural sky and no authored ambient/hemisphere light their shadows from the physical sky's environment capture. That fill was the lighting stack's only render-once, event-ordered term: the IBL cube captured exactly once for a static-sun game (re-captures were purely change-driven), and scene.environmentIntensity was written only at events (skybox apply, fallback-suppression flips from the lights sync). Any silently lost capture/PMREM or mis-ordered suppression handshake left the session with zero ambient fill — shadowed terrain near-black under a clear midday sky, console clean. Two structural invariants replace the fragility:

  • SkyEnvironment re-captures on a 5-second heartbeat even when nothing changed (six tiny cube-face draws + the small-cube PMREM chain at 0.2 Hz; no pipeline or cache-key churn), and its dirty/heartbeat bookkeeping is consumed only after a successful capture, so a throwing capture retries next frame instead of going dark for a period.

  • syncSkyAmbient re-derives environmentIntensity for an active physical-mode procedural sky from retained state every adapter frame (same formula the apply/suppression paths write), so no event interleave can hold the fill at a wrong value for more than one frame.

Healthy frames are pixel-identical (verified against the real Village Meadow spec in Session Lab: authored / no-fill / flat-ambient / noon states unchanged to the luma digit).

  • Terrain-derived placement now samples replicated/spec state (ledger #480): a new sampleReplicatedTerrainHeight (definition generator + marks + replicated terrain:height field layers; voxel: definition + replicated edits) backs every path that BAKES a Y from terrain at authoring time — terrain-relative position resolution (y: { terrain: N } in position-utils/property-helpers/object-api), spline lowering (all 17 sample sites in spline.ts), and scatter placement (resolveSurfaceY + the slope filter via sampleTerrainSurface's new source: "replicated" option). Loaded chunk outputs are never read on these paths: they are realm-local build artifacts that lag a terrain revision until the async rebuilds land — sampling them at spec apply baked the PRE-revision ground (and on a server world could even read the stale CLIENT store), which is exactly how racing-3d's track and cozy-island's boat/fireflies stayed in the sky after their terrain was fixed. The existing re-anchor machinery (spec-apply re-expansion, refreshTerrainAnchors, the runtime field-gated re-anchor system) is unchanged — it now simply reads ground truth that is current the instant the revised definition installs and identical on both realms.
  • Runtime "lived-in ground" queries (sampleTerrainHeight: chunk rescue, camera, NPC steering, api.getTerrainHeight) keep preferring loaded outputs, unchanged.
  • A broken terrain generator never invents terrain anymore: when a generator module exists but its heightAt throws or returns non-finite, heightFromDefinition now returns flat ground (0 clamped into the vertical range) instead of swapping in seeded procedural noise spanning the whole vertical range (the manufactured 0–200m hills content got authored against). The procedural-noise pathway remains only for definitions with no generator module at all. The chunk-build worker fallback chain inherits the same behavior.
  • The spec-apply generator probe (ledger #369 rail — one report per content version via runtime log + deduped DM) now names a wrong signature precisely: heightAt(x, z) / two-plus required parameters reports "expected heightAt(ctx) with a single context argument" (same check for materialAt) instead of the vaguer "returned a non-finite value", and says the terrain renders flat until fixed.
  • Deliberately NOT re-snapped (do-no-harm): literal creator-authored y values (the engine does not know the intent behind a number Savi or a creator computed), players (ledger #186 — players are never terrain anchors), and spline points authored with literal heights without snapToTerrain.
  • Tests: terrain-revision-resnap.test.ts (spline children re-snap under stale loaded chunks, the cozy-island { terrain: N } + scatter shape, the literal-y do-no-harm pin — with divergent stale server/client stores installed to catch any realm-local read) and terrain-generator-faults.test.ts (wrong-signature reported by name; flat — never procedural hills — for a broken generator).
  • 2d-side ortho projection no longer infers zoom from camera height (resolveProjectionCameraState, src/tome/systems/camera-behavior.ts): in side view the camera rides at roughly eye height, so the vertical delta is a small number that breathes with every jump — an unhinted custom camera inferred orthoSize ≈ 1.6 and zoomed harder mid-air (Frontline Push). Cameras without an explicit zoom now take the mode default; explicit zoom/orthoSize (runtime state first, then config) still wins.
  • vignette(x) response recalibrated (updateVignetteOverlay in src/tome/systems/juice-client.ts + the look-pass vignette node): intensity now drives the geometry — opacity eases as 1−(1−i)^1.6 and the feather widens with it (0.2 + 0.45·eased), so low values read as an edge rim and 1.0 closes in. The hidden ×1.4 intensity boost died with it (it saturated everything above ~0.7: vignette(0.5) rendered ~85% opacity and 0.72–1.0 were identical). Existing games with authored vignettes render differently — more range, less drowning.
  • One-shot SFX governor: at the 8-voice per-clip cap the newest sound now always plays — the clip's oldest in-flight copy is stolen and its tail fades under the new attack transient (cull fade widened 15ms → 50ms so the cut is masked) instead of the new trigger being dropped. Under spam fire (~20 triggers/sec on one gunshot clip) fresh onsets no longer go silent; the 30ms retrigger floor is unchanged.
  • Sprite atlas hydration honesty (ensureSpriteAtlasHydration, src/features/SpriteAnimationFeature.ts): the smart-inferred placeholder ({4×4, fps: 8} guessed from the URL) is no longer treated as hydrated state — it neither blocks the real KTX2/PNG metadata fetch nor outlives it, so idle and one-shot clips play at their authored fps/frame-count/loop once the atlas metadata lands.
  • First client spec apply preserves replicated state: a joining multiplayer client's first applySpec ran the player-template reconcile against oldSpec === undefined, diffing every property as changed — stripping the player's replicated DrawSprite and re-running onSpawn client-side without the server-hydrated auto-size (the join briefly rendered the player sprite at 1×1 until netcode repaired the self-inflicted divergence). The reconcile is now gated on oldSpec presence. The render layer keyed model visuals on entity id and treated a same-id, same-model draw/model re-add as "already present" — it only refreshed the material variant and returned, never re-attaching the mesh. That short-circuit was correct only when a representation was actually attached. When the visual record survived but its representation was lost (a long-idle tab dropped the local entity's mesh; an asset still loading whose pending subscriber was never re-fired), the re-add hit the same short-circuit and the entity stayed invisible forever — the per-frame pack (packModelVisuals/syncModelLods) only revisits visuals that already hold a representation, so a re-add is the only thing that can re-attach this entity.

Fix in apps/cf-kernel/src/engine/renderer/three/models.ts (setModel): the same-model material-variant short-circuit is now gated on visual.representation !== null. A same-model re-add to a representation-less visual falls through to attachModelVisual, which re-drives the load — a ready asset attaches now, a still-loading asset re-subscribes (replacing any lost subscriber). Model-id changes (the detach-then-attach branch) are unchanged.

Failing-test-first: model.test.ts > "rebuilds the visual when a same-model re-add reaches a representation-less record (ledger #316)" — a model add that resolves to no attachable representation, followed by a same-id same-model re-add once content is available, now produces a static batch. Red on the old short-circuit (0 batches), green after. Every kernel log line and Datadog intake now ships the booted engine version (ledger #421). cf-edge places the engine semver in container start env as SPAWN_ENGINE_SEMVER (the same value that keys the GAME_CONTAINER DO — see room-runtime.ts's engine identity metadata), but the logger never read it, so DD had no engineVersion to filter logs by — the version-tags gap the client-forensics follow-up (#441) named.

Fix in apps/cf-kernel/src/_entry/server/logger.ts:

  • resolveLoggerIdentity resolves SPAWN_ENGINE_SEMVER into a new engineVersion: string | null field on LoggerIdentity (trimmed; null on older cf-edge / local dev where the env is absent).
  • The booted engine version is seeded once into globalContext so every log line carries it as an indexed engineVersion attribute. Unlike appId/variantId, the engine version is boot-immutable — it keys the DO and never rebinds mid-session — so it does not ride setGlobalContext's rebind path.
  • The DD intake ddtags (buildDatadogIntakePath) now includes engineVersion:<semver> alongside stage/appId/containerInstance, so tag-based log queries in DD can scope to a specific engine version.

The other half of #421 (the input-pipe-starved engine diagnostic being dropped by the server) was already fixed on master — "input-pipe-starved" is in ALLOWED_DIAGNOSTIC_CODES (tome/engine-diagnostics.ts), shipped via ledger #406, and is exhaustively covered by the existing engine-diagnostics test. No change needed there.

Failing-test-first: logger.identity.test.ts — resolveLoggerIdentity reads the engine version from SPAWN_ENGINE_SEMVER (and leaves it null when absent), and the DD intake path carries engineVersion:<semver> from boot. Red on the old code (engineVersion undefined, tag absent), green after. The renderer's model-not-animatable diagnostic (apps/cf-kernel/src/engine/renderer/three/models.ts, warnModelNotAnimatable) fired one message for every static model carrying a named draw/mixer clip: "rebake it with animations … or point the entity at a rigged model" (ledger #307). That advice is correct for a spec model Savi authored, but wrong for a profile-injected player avatar (animated3DCharacter: true) — that model is the session owner's profile avatar resolved at runtime, which Savi can't rebake or repoint (ledger #430).

The two cases are distinguishable at the renderer by the mixer's channel set. The auto-locomotion feature (Animated3DCharacterLocomotionFeature) is the only thing that reads DrawAnimated3DCharacter, and it compiles that config into draw/mixer channels keyed idle/walk/run — so the renderer (a separate realm that only sees the resulting mixer) treats that exact key set as "engine-authored avatar locomotion" rather than a creator's own channels on a spec model.

  • A3DC_LOCOMOTION_CHANNEL_KEYS is now exported from draw-animated-character.ts (the canonical home of the a3dc concept). The locomotion feature keys its writeChannels call from the constant and the renderer's new isAvatarLocomotionMixer matches against it — one frozen source of truth, so the renderer's classification can never drift from what the feature writes.
  • warnModelNotAnimatable branches on isAvatarLocomotionMixer(visual.mixer): the avatar case names the limitation and ends "leave it as-is" (no repair verb); the spec-model case keeps the original rebake-or-repoint nudge verbatim. Diagnostic code, dedupe key, and data payload are unchanged.

Failing-test-first: model-not-animatable.test.ts — an avatar locomotion mixer (idle/walk/run) reports once without "rebake" or "point the entity at a rigged model", and a creator's spec-model mixer still primes repair. Red on the old single-message code, green after. Dead-zone the vehicle's published forward speed near zero (ledger #434). currentVehicleSpeed() is the SIGNED projection of chassis velocity onto the forward axis; at near-zero speed the chassis micro-velocity wobbles around zero, so that tiny projection flips sign every couple of ticks — and client and server, running independent solver noise, flip on different ticks. The wheel-state writeback published the raw value straight to the replicated PhysicsVehicleConfig.vehicleSpeed lane, where the mismatch comparator forgives magnitude (DEFAULT_VELOCITY_EPSILON 0.05) but not a flip: server +0.02 vs client −0.02 is a 0.04 delta from the sign alone, and across two ticks the anti-sign pair routinely exceeds the epsilon — a parked car booked constant corrections (~363ms/s of resim). Distinct from #416's rest-sleep: a car under a held tuning lane (brake/steering/feather throttle) never reaches the sustained-rest window, so it never sleeps — but its forward speed still lives in this band.

Fix in apps/cf-kernel/src/engine/physics/rapier/features.ts: at the single writeback seam, const stableSpeed = Math.abs(rawSpeed) < VEHICLE_SPEED_DEADZONE ? 0 : rawSpeed before quantizeF32 and the world.set. VEHICLE_SPEED_DEADZONE = 0.05 (= DEFAULT_VELOCITY_EPSILON): the comparator already forgives this much, so a value in the band can never read nonzero on the wire. Same threshold and same math on both realms ⇒ parity by construction (no replicated state, no per-side branch), and the band is far below a real driving speed so a moving car is untouched. New failing-first test vehicle-speed-deadzone.test.ts reproduces the sign-flip (raw ±0.02/±0.03 near-zero nudges) and pins exact-0 publish + anti-sign two-world equality + real-motion passthrough. In singleplayer, run_script lands on the server room but executes on the player's client authority (tome/script-forward-server ships it as a script.exec control message; the client answers over the wire). When there was no client to service the forward, the system kept the entry queued for the full FORWARD_SCRIPT_TIMEOUT_MS (20s) deadline before resolving with "no connected player client" — and Savi's exec RPC sits above that, so the felt wait was ~30s of nothing (ledger #444, 132/395 family).

Two no-client shapes both incurred the full wait:

  • Empty / no-Ready connection (tab fully disconnected): findAuthorityClientId returned undefined and sendPendingForwards treated it as a mid-join window, keeping the forward queued until the deadline.
  • Frozen / backgrounded tab (socket dropped, connection still Ready with detachedAtMs set in the disconnect grace window): findAuthorityClientId only checked phase === Ready, so it picked the detached connection as the authority and queued the script.exec into a controlOutbox that egress never drains (the egress loop skips detachedAtMs !== undefined). The forward then timed out at 20s with "the player's client did not respond". DISCONNECT_GRACE_MS is the same 20s as the forward deadline, so a frozen tab guaranteed the full wait.

Fix in apps/cf-kernel/src/tome/systems/script-forward.ts:

  • findAuthorityClientId now also requires detachedAtMs === undefined — a detached Ready connection can't receive the message (same predicate egress uses), so it is no longer treated as a serviceable authority.
  • New hasConnectingClient(world) returns true only for a genuinely mid-join client (phase === Loading) — the boot-race window where the script.exec will become deliverable any tick. A detached Ready connection is explicitly NOT connecting (socket gone, grace window == forward deadline).
  • sendPendingForwards: when there's no serviceable authority AND no mid-join client, the unsent forwards now resolve immediately with the honest "no connected player client" error rather than waiting out the deadline. A real mid-join client still keeps the forward queued until either delivery or the existing timeout.
  • The "no connected player client" message is now a shared NO_CLIENT_REASON constant used by both the fast-fail and the (still-valid) timeout path for a mid-join client that never finishes joining.

No new code path or bridge — the existing pending/deadline machinery is unchanged; this only decides per-tick whether the no-authority case is "wait for a connecting client" or "answer now".

Failing-test-first: script-forward.test.ts — "fails fast when there is provably no connected client" (empty table, honest error on the first tick) and "fails fast when the only connection is a frozen/detached tab" both red on the old code (resolved undefined after one tick), green after; "keeps waiting through a genuine mid-join window" pins that a Loading client still gets the full deadline. The old "times out loudly when no client is connected" test (which asserted the empty-table case waits the full timeout — the bug) was removed; its scenario is now covered by the fast-fail test. Adds a path shape to the heightmap flatten mark (ledger #482). Previously a road was authored as many independent circle/rectangle flatten marks, one per "pad". With height: { terrain: offset } each pad resolved its target height from the natural terrain at its own center and lerped to it independently — so adjacent pads flattened to different heights, reproducing the terrain's relief as a chain of steps (the rollercoaster). There was no grade relationship between consecutive pads and no shared/interpolated surface along a path.

The fix makes the road one mark with a continuous, grade-limited height profile:

  • marks-types.ts: the flatten shape union gains { kind: "path"; points: [number, number][]; width: number } plus an optional maxGrade (default 0.25 ≈ 14°). Schema mirror added in @spawn/tome-schemas (TerrainFlattenShapeSchema).
  • marks.ts resolveFlattenPathProfile: samples the centerline with the existing centripetal spline (same sampler rivers use, so curved roads read as the curve), resolves each sample's target height from the natural terrain (or absolute), then runs a forward+backward min-pass clamping each step to maxGrade * segmentLength. The result is a monotone-grade-limited surface that still tracks the terrain everywhere the grade allows. The profile is stored on the mark entry and read at a point's arc-length projection (sampleFlattenPathHeight), so the whole strip flattens to one road, not per-point pads. An absolute-height path stays a single flat road (no grade pass needed).
  • computeFlattenShapeDistance / resolveMarkBounds handle the path strip (distance to polyline minus half-width; bounds = polyline AABB expanded by half-width + falloff).
  • scatter.ts: a path-shaped flatten clears scatter along its strip + falloff band (a road shouldn't grow trees on it), matching the existing area-shape exclusion.

The grade limit lives entirely in the height-profile resolution (a pure function of the centerline samples + natural terrain), so server and client produce identical roads. Area shapes (circle/ellipse/rectangle) are unchanged — they already flatten to a single height. This is the engine half of #482; the skill/authoring half (teaching Savi to reach for path flatten over dotted pads) is separate.

Failing-test-first: marks-flatten.test.ts > path-shaped flatten with terrain-relative height limits grade between pads — a road centerline crossing a grade-2.0 natural step; the observed surface grade between dense samples must stay ≤ maxGrade (0.25). Red on old code (the step passed straight through at grade 2.0), green after. collectSpecAssetEntries (spec-assets.ts:207-213) iterated place.terrain.materials with a bare for...of guarded only by kind === "heightmap". TerrainHeightmapDef.materials is typed Array<...>, but the value here is the raw, un-Zod-normalized persisted spec — and a heightmap terrain can carry materials as an object {} (voxel terrain's materials is a Record, so a place flipped voxel→heightmap, or a raw definePlace/updatePlace that authored materials: {}, leaves a non-array behind). Iterating an object throws TypeError: {} is not iterable, which applySpec's try/catch logs as tome.apply_spec.failed and aborts the entire apply (ledger #486; the cross-game trickle co-occurring with terrain "Material id is required" — terrain-config's validateMaterials already handles !Array.isArray(materials), asset collection did not).

Fix in apps/cf-kernel/src/tome/spec-assets.ts: the iteration guard now requires Array.isArray(place.terrain.materials) instead of mere truthiness — matching terrain-config's own posture (non-array materials = no terrain materials to collect). A malformed object degrades to "no terrain texture assets from this place" rather than crashing the apply; a real array is unaffected.

Failing-test-first: spec-assets.test.ts — collectSpecAssetEntries over a { kind: "heightmap", materials: {} } place. Red on the old code (the exact {} is not iterable TypeError), green after (returns []). The exec deadline-admission projection (ExecHost.expireDeadEntries, ledger #376 exec-off-sim-thread) charged every queued lane ahead the WORST-CASE per-exec ceiling (scriptSyncBudgetMs + watchdogGraceMs = 7s with defaults). Real callers give run_script a 28s deadline (EXEC_QUEUE_DEADLINE_MS), so the 5th queued entry projected its start at 4×7s = 28s — right at the deadline — and was failed INSTANTLY with the queue-pressure error, even when every exec was actually completing in milliseconds (ledger #533).

Fix in apps/cf-kernel/src/tome/exec/host.ts:

  • The projection now uses REALIZED/measured time, not the worst-case budget. The in-flight lane is charged its TRUE remaining watchdog time (watchdogDeadlineAtMs(inFlight) - now — it has been running; the watchdog frees it by that clock). Each queued lane ahead is charged realizedLaneHoldMs, an EWMA of actual dispatch→free wall-time across completed execs.
  • realizedLaneHoldMs is undefined until the first exec completes, so a cold queue projects off the in-flight lane's true remaining time alone (queued lanes charge 0) — fast execs are admitted instead of pre-failed on a worst-case guess. It is folded in settle (normal completion) and terminateInFlight (a watchdog kill is the slow-lane evidence), EWMA α=0.5 so one slow exec lifts the estimate and a run of fast ones decays it.
  • A genuinely slow queue still bounds: a stuck in-flight lane's remaining watchdog time (and a climbing realized estimate once slow execs complete) carry the projection past short deadlines, so entries that truly cannot make their deadline still fail fast with the honest queue-pressure error.

Failing-test-first in apps/cf-kernel/src/tome/__tests__/script-dispatch-budget.test.ts: "runs five fast queued execs even at the real 28s caller deadline" — red on the old code (5th entry pre-failed at tick 1), green after. The existing "fails fast an entry whose projected start is past its deadline" test was updated to express the bound through the in-flight lane's true remaining watchdog time (the honest mechanism) rather than the old worst-case per-queued-lane charge. Static (fixed) rapier colliders now re-place transform-driven instead of callsite-driven, fixing a drift-dominant player-feet misprediction storm when a parented static-physics child is moved (ledger #550, dump 1e06e2e4). A fixed rapier body (RigidBodyDesc.fixed) never re-places itself, and syncWorldFromRapier's stable/signature fast paths skip statics entirely — so a moved static's collider only tracked the move at three special-case callers (spec-apply transform edit, root script-move, parented-child hierarchy solve). The client hierarchy solve is gated to client-predicted entities, so a server-realm furniture child Savi dragged updated its replicated WorldFeetPosition/WorldRotation (mesh moved) but never re-placed its collider on the client — one-sided collider lag, the player colliding with stale geometry and resimming every tick.

Fix in apps/cf-kernel/src/engine/physics/rapier/sync.ts:

  • New staticTransformReplaceNeeded(world, runtime, entity) — a REALIZED static's collider transform is now a pure function of its replicated WorldFeetPosition/BodyPosition/WorldRotation, compared each tick against the realized-pose caches (getPositionCache/getCenterCache/getRotationCache, the same caches entityNeedsTransformSync uses) via the existing positionsDiffer/rotationsDiffer epsilon.
  • syncWorldFromRapier's stable-static fast path and signature-match skip now consult that check; on a real transform delta they dispose+rebuild the body at the new pose (disposeStaticForTransformReplace, which first aligns PhysicsBodyState.rotation to the current WorldRotation so the rebuild's initializeBodyState orients correctly — mirrors the existing realign in syncPhysicsBodyToComponents' static branch).
  • Identical input on both sides (replicated World* transform) ⇒ parity by construction; no client-only state is read, so the fix removes the dependence on the client-prediction gate rather than widening prediction.

Cost: the re-place is epsilon-gated against the cached realized pose — a stationary static reads false and stays on the zero-cost stable fast path (no per-tick destroy/rebuild churn). Dynamic and kinematic body handling is unchanged.

Failing-test-first: static-collider-transform-tracking.test.ts (server + client worlds) — a moved static's rapier collider translation/rotation now tracks its replicated transform after a plain syncWorldFromRapier, and a stationary static keeps the same body identity across 18 ticks. Red on the old code (collider stuck at the old pose), green after.

  • Live rooms for unpublished games no longer retry spec hydration forever (ledger #553): after ~90s of deterministic "no publish exists" 404s the room goes terminal, rejects joins with an honest room.unpublished control frame + close code 4432, and emits the spawn.kernel.spec_hydration_stuck error event. Transient fetch failures keep the unbounded retry; the publish-press race (publish row committing 20-90s after a live room boots) still resolves through the retry. A later publish poke or a fresh boot clears the terminal state.
  • gameSpec.latest() (gsdk) now returns a structured { notFound: true, reason } on 404 instead of null, carrying kiln's resolver reason; the kernel's "No game spec found in Supabase" error string is replaced with the real reason.
  • Fixed a duplicate GameRoomRuntime booting in the container main thread (Bun's main thread has globalThis.postMessage, which the worker-entry guard mistook for a worker context) — this was doubling spec-hydration fetches and running a second sim pump for the boot room. God-mode right-click selection only committed for authored (in-spec) entities: commitTapSelection in tome/god-mode/systems/click-to-select.ts gated on isAuthoredEntity, and non-authored hits fell through to the terrain path as a silent deselect. Meanwhile the designed affordance for exactly this case — the lockedRuntimeEditor rule in default-editors.ts (a single "Edit with Savi" action chip that DMs Savi the object id) — had been unreachable since it shipped (#6496): the rule resolves off the SELECTION, and selection could never land on a runtime entity. Jacob's ruling (ledger #563): locked entities select and surface the chip.

Fix:

  • commitTapSelection now also commits when the hit resolves to a selectable runtime entity: renderable presence (DrawPrimitive/DrawMesh/DrawModel/DrawSprite/DrawText/DrawAnimated3DCharacter), not a player (TomeBehaviorRef.specId === "player", the same check isLockedRuntime uses), not a gizmo (already excluded via anchorEntityOfGizmo), not god-mode tooling (below). No editor/applier changes needed — applySelectionState never gated on authored, and the selected-cluster pass publishes the locked chip through publishActionPanel once selection lands (pinned by a new integration test).
  • The original reason for the authored-only gate stays fixed: the selected scatter-bed's footprint-outline ribbon (selection-outline.ts) is real raycastable geometry draped on the terrain, and selecting it back was the "can never deselect the bed" trap. Engine-internal god-mode helper entities (footprint ribbon, selection-rim twins, feedback-sfx one-shots) now share a tooling id prefix — GOD_MODE_TOOLING_ENTITY_PREFIX / isGodModeToolingEntity in tome/selection-utils.ts, the same id-marker idiom gizmo exclusion uses — and commitTapSelection skips marked tooling in the hit scan, so a click on the ribbon falls through to the terrain/footprint path (lands on the bed or deselects).
  • Behavior pinned unchanged: authored entities select exactly as before (root-first drill-down included), players and renderless runtime helpers don't select, gizmo hits still resolve to their anchor, terrain/empty still deselects. A selected runtime entity gets ONLY the chip — no transform handles (nothing to write back to).

Failing-test-first: tap-select.test.ts > "a right-click tap on a runtime-spawned (non-spec) renderable entity selects it" — red on the old gate (ids: []), green after. New pins: ribbon/player/renderless-helper right-clicks stay on the deselect path; default-editors.test.ts > "selecting a runtime-only entity publishes the 'Edit with Savi' chip — and nothing else — to the action panel". setScript parks the spec snapshot it wrote in TomeSpecUpdateResource for the tick-boundary apply. A later fold in the same run_script (mirrorPersistentSetPropertyInSpec and the whole mirror family) advanced GameSpecResource without refreshing that parked request, so specUpdateSystem drained the setScript-era snapshot wholesale and erased the fold from the live spec — while ScriptMutations persisted both writes, which is why the value "reappeared" on reload. Same mechanics on both realms (exec-worker overlay via the TransactionLog, live-world withPersistence).

Fix in updateSpecResource (src/tome/api/object-api.ts): after a successful head write, a pending request that IS the previous head advances to the new head (reason/baseline preserved), so the drain commits every fold in program order. The gate is reference identity on live worlds and the already-trusted structural hash inside the exec overlay (its setResource clones staged values, so identity dies at that boundary). Writers that request applies without writing the head (setObjectProperty.behavior et al., placeCleanup) fail the gate and keep their existing semantics. The fold still runs inside the same transaction/tick — no new ordering, deterministic-sim contract intact. Repro-first regression test drives the production path (inline exec transport, script-dispatch → spec-update → mutation-drain): 3 of 4 cases failed pre-fix, 4/4 green now. drawn-art.md's defensive ref-first ordering died with the bug. The interpreter's spec→world path (applyAppearanceProps, src/tome/interpreter.ts) rebuilt kind:"custom" geometry from a hand-copied field list carrying only positions/indices/normals/uvs/revision — dropping colors, emissive, metalness, roughness — and hardcoded materialKeyForBespokeVertexPbr(key, false), so even carried buffers would have rendered through the non-VPBR material. The primitive property getter stripped the same four arrays on read-back.

BespokeGeometrySpec is directly assignable to BespokeGeometryInit, so both the interpreter and the authoring setter (src/tome/api/properties.ts) now call createBespokeGeometryValue(spec.geometry) whole — no field list anywhere left to drift — and the material key derives from hasVertexPbrGeometry like the live path. Read-back returns the full geometry; BespokeGeometrySpec gains the textureRuns field the engine value already supported, making spec ↔ component round-trips total. Regression test drives applySpec end-to-end (paint arrays + textureRuns + VPBR material key + property read-back) and was verified red against the old code. quantizeWheelNotches (src/engine/input/raw-capture.ts) claimed "exactly one WHEEL_NOTCH per physical detent" but accumulated raw deltaY against fixed thresholds (100px / 1 line), which was false under macOS acceleration (a single detent's delta ranges from a few px to several hundred → 0 or 2+ notches) and on 3-lines-per-detent OS settings (3 notches per click).

The quantizer now normalizes per deltaMode (pixels ÷ 100, lines ÷ 3, pages = 1 detent) and emits at most one notch per wheel event. Real devices deliver one event per physical detent — a fast flick is a burst of events, one per click — so the per-event clamp is what makes one-pulse-per-detent true under acceleration. Sub-detent leftover carries forward (trackpad fractions and 1-line OS settings accrue into clean single steps); overshoot beyond a whole extra detent within one event is acceleration inflation, not future intent, and resets instead of banking a phantom notch. Direction-reversal and focus resets unchanged. processWheelNotch's same-frame collapse comment now states what its test pins; game-ui.md teaching is test-pinned per claim. 12-case device matrix added; 134 input tests green.

  • Dark scenes survive their grade (round-3 god-rays anatomy): the look pass's contrast pivot now sits at sRGB-0.5-in-linear (~0.2140, one exported constant shared by scripted gradeFx and the vocabulary grade) instead of effectively pivoting at sRGB ~0.73 — contrast no longer crushes everything below ~sRGB 0.19 to black. Vignette default is circular (was a diamond falloff at wide aspect).
  • Transactional run_script guards can no longer kill a room (ledger #560): all seven "not available in transactional run_script mode" guards route through a sticky fault channel — when creator JS swallows the guard throw (Promise executor, .then chain, try/catch), the exec fails at the result boundary with the explanatory message instead of silently returning ok:true with the call dropped. Process-level unhandledRejection nets in both the runtime worker and the exec worker contain script-origin floating rejections (Bun Workers' Web-style unhandledrejection listeners never fire — any float was previously fatal to the room); sync crashes still escalate loudly.
  • Fixed: a rest-slept vehicle chassis (#416) could be stranded permanently asleep under held throttle (ledger #580). Three layers: setWheelEngineForce/setWheelBrake/setWheelSteering now defer to merge as typed deferred ops under the script-transaction overlay (the inline path landed the component row but dropped the controller poke + body.wakeUp() — null runtime on the overlay world); syncVehicleControllers reconciles a replicated wheelStates row holding nonzero engineForce on a sleeping chassis (reseeds controller lanes, wakes, clears replicated sleep status); updateVehicleControllersForSubstep wakes a sleeping chassis whose controller holds nonzero engineForce, every substep. Rest-sleep for genuinely parked (all-zero-lane) vehicles is unchanged.
  • updateChannel no-ops on identical patches (object-api.ts): a patch that lands exactly on the current channel state skips the write entirely via an allocation-free field compare — cheaper than the store-level recursive deep-equal it short-circuits. One-shot re-fires are structurally exempt: a clip-named call on a loop: "once" channel bypasses the compare before suppression is possible, so retrigger semantics are byte-for-byte unchanged. No realm branch in the gate (inputs are replicated state + script args + tick), so client/server stay symmetric under prediction resim.
  • 2D skill corpus: the platformer worked frame gates locomotion weight on grounded-and-moving and gains an air channel covering the whole airborne range (no vy dead zone at the jump apex); mixer guidance teaches per-tick calls derived from getState()/isGrounded() ("identical calls are free") instead of script-side caches, and timers ride game time, never wall clocks.
  • Behavior-watchdog budget parks re-validate their earn-time safety gate while held (behavior-watchdog.ts, ledger #580 round 2). A park earned while an entity was server-only simulated (pre-mount) used to survive transferControl: the mounted car's update() — the ONLY writer of a generated vehicle's wheel control lanes — stayed skipped, so held W landed in state via onInput and died there, leaving the rest-slept chassis frozen at 0.00m while mount/camera/toast all worked (both round-1 sim-side self-heals read lanes/rows that only update() writes, so they saw zeros by construction). isBudgetParked now drops an entry whose entity has an active TomeController or is otherwise no longer one-sided-safe to park, and the earn gate (canPark) refuses actively controlled entities in singleplayer too (multiplayer already refused them via isServerOnlySimulated). Cost: paid only by entities carrying a live park (size-0 early-out unchanged); zero physics-path changes, so #416 rest-sleep for idle parked/mounted vehicles is untouched.
  • Budget parks are now Datadog-visible: one tome.behavior.budget_parked warn per park event (≤ once per 10s cooldown per scope) beside the existing in-room runtime-log breadcrumb. Round 2's gate ambiguity — a clean DD window that proved nothing about parks — was this exact hole.
  • New integration pin vehicle-sleep-mounted-input-wake.test.ts: live multiplayer server stack, real netcode join (spawnPlayer), a REAL watchdog park earned by a busy pre-mount update(), rest-sleep, mount via interact input frame → interaction-dispatch → transferControl, then 130 ticks of held forward frames through tomeInputApplier's control-target reroute — asserts the car wakes and drives (red on the unfixed build with the field's exact 0.00m signature).

Engine v5.0.9

Released June 9, 2026

  • 3D models are now automatically optimized for better performance, with additional simpler versions used as they move farther away.
  • Objects built from connected moving parts now stay together during movement and animation.
  • Fixed a bug where players could repeatedly fall through solid terrain instead of being placed safely back on the ground.
  • Fixed a bug where heavy terrain edits could leave the ground non-solid on the server — players fell forever while the world looked normal. The engine now detects and repairs missing ground under players automatically, and reports it instead of failing silently.
  • Fixed a bug where leaving god mode could leave your character deaf to controls — interactions dead and jumps snapping you back to spawn until a refresh.
  • Fixed god-mode edits in busy worlds appearing to fail with a 30-second hang — saves now confirm immediately.
  • You can put the game in a box now: build a HUD around it, shrink it into a panel, letterbox it to a fixed shape — just drop <spawn-canvas> into your UI and style it like any other element
  • Card games, strategy views, retro consoles-in-a-frame: the 3D view is finally just another piece of your layout
  • Things can jiggle now! Set jiggle: true on any object — slimes squash when they land, tails and ears sway and whip on characters with dangly bones, signs wobble on their posts. Tune it with amount, bounce, and gravity.
  • Games with HUGE crowds are now real: armies, swarms, flocks of thousands of units that all think and move every tick
  • A runaway script can no longer freeze your game — the engine benches it for a few seconds and tells Savi exactly what was slow
  • Attach things to the right PART of a model — attachment: { socket: "muzzle" } puts a flash on the gun barrel, a hat on the head, a rider in the saddle. Savi names the spot; the engine finds it on the actual 3D model.
  • New getSocket lets scripts aim from those spots too — spawn projectiles from the muzzle, not from the object's feet.
  • Shadows no longer pop in and out when you turn the camera: objects behind or beside your view — walls, pillars, props, characters — keep casting their shadows into the scene.
  • Point and spot light shadows look noticeably sharper and softer-edged: shadow maps now use the free space in the shadow atlas instead of leaving it idle, and shadow edges get a proper soft falloff instead of a hard stair-step.
  • Preview thumbnails while a model generates now show a clean cutout of the object. White-ish objects no longer turn semi-transparent in the preview, and previews appear several seconds sooner.

technical notes

Exec can no longer monopolize the sim tick loop (ledger #376). A wisp's 30-second room.exec froze a live prod room's simulation for the full 30s — tome/script-dispatch runs each queued script synchronously inside the simulation phase, on the same thread as the tick pump (runtime-worker's setInterval), so one long script pinned every tick, players disconnected, and the recovery fed ledger #333's cap-adopt storm. Two budgets now bound what the engine controls: (1) the per-tick drain is time-boxed — each script still commits transactionally within its tick, but another queued script only starts while inside the slice budget, so a pile-up of slow execs spreads across ticks instead of compounding into one frozen mega-tick (at least one script per tick, no starvation); (2) each script gets a synchronous execution ceiling (5s) checked on every sandbox call into the engine (api.*, bare helpers, require) and at the result boundary — past it the exec aborts, the transaction rolls back, and the caller gets a loud "split the work into smaller run_script calls" error, even when user JS catches the abort. Parity-safe: only the authority realm executes a script and its effects ride the wire, so budgeting changes which tick a script runs, never side-local state. Residual, documented in-code: a pure-JS busy loop that never calls back into the engine still pins the thread for its duration — in-thread preemption of synchronous user JS is impossible.

  • Kiln's model postprocess pipeline now sends supported generated and uploaded GLBs through the Rust/wgpu remesher, which selects a triangle target, rebuilds UVs, rebakes material maps, and emits native LOD1-3 variants for static opaque assets. Unsupported inputs and worker failures fail soft to the original asset.
  • Static model batches now pool materials by model ID, LOD, and material signature instead of sharing one material across every resident LOD. Each remeshed LOD can therefore use its own baked textures and material state without contaminating another variant during automatic or explicit LOD switches.
  • The documented lodOverride range now matches the emitted base plus LOD1-3 asset set.
  • Fixed renderer snapshot composition for deeply nested parent-child rigs by interpolating authored local transforms and composing them against the displayed parent pose.
  • Fixed terrain fall-through rescue leaving physics transforms internally inconsistent: the post-physics rescue updated WorldFeetPosition but left BodyPosition unchanged, so Rapier's external-transform sync and Mantle's center-pose sync could consume different poses and re-drop the entity. Rescues now derive the matching body center from the rescued feet, collider geometry, scale, and world rotation so both backends share one coherent pose on the next step (terrain-systems-shared.ts).
  • Fixed the server-plane terrain collider gap (ledger #381, the #285 invariant extended to the server spec-apply/patchTerrain path): heavy terrain churn (generator swaps, reseeds, repeated patchTerrain across an engine upgrade) could leave a chunk's TerrainChunkBuildState "done" with a still-matching inputsHash while its collider plane (PhysicsBodyConfig + TerrainChunkColliderPayload + TerrainChunkCollider) was stripped or stale. The server build system dismissed every dirty mark on such a chunk as spurious, and the physics-step presence-parity check could not see it (it requires a PhysicsBodyConfig claim) — the server character free-fell forever while the client rendered solid ground. submitServerBuildCandidates now verifies the collider plane before dismissing a hash-matching mark and rebuilds in place on violation, without releasing surviving artifacts during the rebuild window (isServerTerrainColliderPlaneIntact, server-terrain-system.ts).
  • Added the server anchor-chunk live-collider watchdog (terrain/server-collider-watchdog, terrain-systems-shared.ts): every 150 ticks it re-derives the terrain gate's anchor chunk set and asserts each anchor chunk of a terrain place holds an intact collider plane or has a rebuild in flight. Violations persisting two sweeps fire a loud [terrain/server-collider-watchdog] console diagnostic plus a runtime-log entry (visible to Savi via getLogs(), code server-terrain-collider-gap) and remediate through the ordinary dirty-mark pipeline. Also reports the lost-definition class: spec declares terrain for an occupied place but no server terrain definition is installed. Server-authored diagnostic — no ALLOWED_DIAGNOSTIC_CODES entry involved.
  • Fixed server-side player input-application death after god mode (ledger #386, dump aa3bd931): god-mode exit is now one shared teardown (tome/god-mode/session.ts) used by every exit path — the toggle, disconnect-during-god-mode (disconnectPlayer), world reset (resetTomeWorld), and a join reconcile in spawnPlayer that reaps any god-mode wreckage keyed to the joining player id. Previously a disconnect left the hidden god entity and its TomeController half alive, and a reset left TomeGodMode on the player with a dead god entity (api.isGodMode() stuck true), both of which made the body deaf to input for the rest of the room's life.
  • Control mappings resolve toward "no control" instead of resurrecting: TomeControlTarget (controller side) is the authority and TomeController a derived back-pointer — getControllerId no longer re-creates a controller's TomeControlTarget from a stale back-pointer (the bridge that let a leftover god entity capture a fresh player's input), it clears the stale index on the authority.
  • Added an authority-side input-routing liveness guard in the dispatch path (tome/input-liveness.ts): a control target pointing at a god entity without a session, or a god-mode session without a god entity, heals the same tick (clear + full session exit, this tick's input reaches the body); a redirect whose dispatch chain dead-ends (no behavior / unresolvable ref / faulted) auto-clears after a 10s dead window. Every heal is loud — Datadog log, runtime log (Savi's getLogs), and a deduped DM. Working redirects (driven vehicles, controlled NPCs) are never touched; the multiplayer client never mutates routing state side-locally.
  • Made input-application liveness dump-visible (ties ledgers 135/315): the perf-rollup mode block now carries inputRouting (local control target, god-mode presence, seconds since the body's last behavior input dispatch), and the kiln dump summary renders it as the Input: line in the Session block — a dump can now answer "did this player's body receive input dispatches" directly instead of inferring from srv=undef ring rows.
  • Rooms now announce their identity (x-spawn-room-id) on SDK spec-mutation persists (@spawnco/server gameSpec.applyMutations), so kiln can leave the authoring room out of the post-save fanout poke. The echo poke used to park at the author's pendingPersists causal barrier — which was waiting on the very persist response kiln was holding while it awaited the poke — a three-party circular wait broken only by the 30s update-RPC timeout (ledger #352). Kiln's side (deferring the fanout off the response path, skipping the author on non-replace pokes) ships independently; old engines that don't send the header keep full-fanout behavior and are still fixed by the deferral alone. Replace resyncs (rejected batches, ledger #326) always reach the author regardless of the header.
  • Added the <spawn-canvas> UI slot: placing the element in a spec.ui.render HTML layout makes the game canvas track that element's rect (position/size/border-radius) instead of filling the viewport; RenderSurfaceSize derives from the slot rect × dpr. No slot → fullscreen, byte-identical to prior behavior
  • Canvas-relative input remap across every consumer: pointer NDC (clientToCanvasNdc), world-pointer gating (presses only land over the slot), pointer-lock prompt rect, touch controls anchor to the slot, screen-space juice FX (letterbox/vignette/flash) re-anchor; pointer-raycast and pointer NDC freeze the last in-slot ray when the cursor leaves the window
  • Slot is pointer-events:none (forced); transparent backdrop so chrome composites over it; one slot per layout (extras + transformed/clipped ancestors report a UI fault, no crash)
  • Jiggle physics: jiggle: true (or { amount, bounce, gravity, bones }, all knobs 0..1, null removes) — spring-damper secondary motion as one object property. Two renderer-side tiers picked automatically: bone tier (Dynamic-Bone-style spring chains on skinned rigs, auto-detected dangly bone names like tail/ear/hair/antenna, runs post-mixer/post-IK, rotation-only, length-derived pendulum frequency, gravity droop, swing/stretch clamps, animated-baseline restore) and object tier (whole-object lean + volume-preserving squash-and-stretch composed into presentation op values in synthetic-transform-delta).
  • All motion state lives renderer-side — the sim never reads a jiggled transform; nothing to mismatch, nothing to resim. Config is one replicated component (draw/jiggle). Object-tier excitation is arrival-sampled from the snapshot ring's authoritative arrivals and dt-scaled: feel is frame-rate and tick-rate independent, springs rest at constant velocity, squash gates on uprightness. The bone tier claims the entity so characters never double-jiggle.
  • Jiggling models demote from horde batching (baked GPU palettes can't bend). Object-tier jiggle applies to world-timeline entities; hierarchy children whose presentation is parent-relative don't wobble from the parent's motion (the carrier wobbles, the cargo rides it).
  • New closed-form damped harmonic oscillator core (stepSpringDamper/stepSpringDamperVec3) — unconditionally stable at any dt.
  • Getter returns effective knobs (jiggle: true reads back as { amount: 0.5, bounce: 0.5, gravity: 0.3 }). Schema agrees with the live normalizer and accepts null.
  • Added the scripted-systems bulk verbs to ObjectAPI: query({ select: "ids" }) (bare-id queries with zero per-match materialization, also on CameraAPI), readPositions(ids, out?) (SoA bulk feet-position read, NaN-filled for missing ids), setPositions(ids, xyz) (bulk feet-position write with octree reinserts + physics pose sync deferred to batch end), and scratch(key?) (server-side, never-replicated working memory that survives script recompiles, decays after ~30s idle)
  • Added the behavior watchdog: authority-side per-tick wall-clock budget (half a tick, clamped 8–100ms); 3 strictly-consecutive over-budget ticks park a server-only-simulated entity's update() for a 10s cooldown (auto-unparks; onInteract keeps running; never rides the durable fault ledger)
  • query() internals refactored around a single emit-core; existing query behavior byte-identical (path heuristics, stats, validation asymmetries pinned by tests)
  • syncFeetPositionPhysics gained a no-body early-out; missing-target warnings now name the calling method
  • New skill scripted-systems (manager pattern canon, the three verbs, chunked spawning, scratch-vs-state law) + SWARM example; zoo gains a Swarm place; bench: scripts/bench-scripted-systems.ts (5k units: 10.2 → 7.5 ms/tick vs legacy path, query-side allocations eliminated)
  • Semantic sockets: attachment: { socket: "muzzle" } on a parented object anchors it at a named point on the parent's model, resolved by a MagicCDN vision pipeline (multi-view VLM pointing + verification on the GLB). Resolution is async: unresolved children sit at the parent origin and snap to the anchor when the socket metadata lands; the engine warns through getLogs if a socket stays unresolved past the backoff ramp.
  • Resolution rails follow the bounds-prefetch pattern: the authoritative world polls MagicCDN's socket JSON (?socket=<name>, no auth) and writes spec.assets.metadata[modelId].sockets[name]; clients warm generation through the player's cookie session. Socket entries replicate with the spec and persist via patchAssets.
  • Socket anchors compose identically in the authoritative and render hierarchy solves (entity-local offset x parent GeometryScale, position lifted by groundOffsetY so it's mesh-relative, not feet-relative). Bone-attached subtrees keep renderer-owned posing; sockets inside them ride the live bone transform.
  • New ObjectAPI getters: getSocket(name) / getObjectSocket(id, name) → { position, confidence } | { resolving: true } | null, world-space from replicated data only — deterministic across realms. Unresolved lookups kick off resolution and report { resolving: true }. Bone-attached objects answer null (no live bone transform on the server).
  • composeHierarchyTransform gains an optional parentLocalAnchor parameter (joins after the pivot fold, before parent scale/rotate/translate). Socket-attached children are excluded from the synthetic rel-space presentation compose, same family as the bone exclusion — their drawn pose keeps the server-composed anchor.
  • Fixed shadow popping from camera-frustum instance culling: the instance packers (static model batches, primitive batch lanes / oversized pools, skinned hordes) culled instances against the bare camera frustum, and since the packed buffers feed the shadow passes too, an off-screen caster's shadow vanished the moment the camera turned away from it. Shadow-casting slots now cull against the camera frustum swept along the sun's travel direction (sphereCulledBySweptFrustum + FrustumSnapshot.sweep, handlers/cull.ts): per-plane the swept-segment test is exact, and the light sync publishes the sun direction each frame (three/lights.ts). The 200 m sweep borrows CASCADE_LIGHT_MARGIN as a heuristic anchored at the camera frustum — a deliberate keep-rate trade documented at the constant (CSM's own caster clip is anchored at the cascade box; a tall caster slightly past the sweep can still render into a far cascade at low sun). The snapshot revision tracks material sun moves (>~0.6°) so packs re-evaluate; sub-degree drift keeps the cache. Sprites (never cast) and transparent-only batches skip the sweep; the GPU decoration compute cull is a named follow-up.
  • Local (point/spot) shadow-atlas sampling upgraded from a single textureSampleCompareLevel tap to a 4-tap rotated-grid PCF kernel (ClusteredLightDataNode._setupAtlasShadow), gated behind If(inFrustum) so cone-exterior fragments pay nothing. All four taps sample the same texture UUID — binding/sampler counts and shader structure unchanged. The normal-bias law follows the kernel: ATLAS_NORMAL_BIAS_TEXEL_SCALE 1.5 → 2.5 and ATLAS_NORMAL_BIAS_MAX 0.5 → 0.85, covering the off-center taps' slope-proportional depth error out to the kernel's ~1.5-texel worst-case reach.
  • Shadow-atlas headroom promotion (ShadowAtlasScheduler): when the whole candidate set, priced one coverage bucket up, would keep promoted demand ≤ 55% of the atlas, every slot gets that extra bucket (never past the tier's maxSlotSize). Demand prices every candidate at its promoted coverage size regardless of eligibility, so frustum crossings, distance-fade, and adaptive shadow-distance steps cannot swing it; transitions additionally require a dwell (enter ~4 s, exit ~0.2 s) so spawn/despawn cycles can never flap the bonus. A promotion the buddy allocator cannot satisfy records a denial keyed to the allocator's release epoch — the slot holds its fallback cell with a live shadow instead of churning acquire/release at fade 0, and retries once space actually coalesces. Dead buttons from a collapsed input lead now self-heal, and Savi gets told (ledger #406, dump 8966b5d8).

A client whose tick lead collapsed (page refresh resuming into a long-lived room) kept sending one input frame per tick, but every frame landed for a tick the server had already consumed. The buffer accepted each frame, then swept it too-late — so the server applied ZERO inputs (every ack absent), every button/key in the game went dead, and nothing faulted anywhere. The state was stable: the late frame sitting in the pre-consume buffer made the throttle read "frames are flowing, nothing to speed up" (bufferedFrames === 0 gated the missed-input rail) AND reset the missed streak, so the speed-up that would close the gap never fired. Creator enfeul lost 88 minutes to this while Savi rewrote healthy scripts — from her side every probe showed correct authoring, because the only evidence (per-tick ack inputKind) lives client-side where she has no eyes.

Three pieces:

  • Input buffer health now reports lateCount (frames dropped too-late this tick, insert-late + burst-sweep) — the discriminator between "client behind" (late frames: speed up hard) and "client ahead with a gap" (future frames buffered: leave it alone).
  • The throttle treats a chronically-late stream as sustained speed-up pressure instead of a dead zone. A collapsed lead now closes at the 1.1x ceiling in a few seconds via the existing flow-control wire — no protocol change.
  • Client-side starvation diagnostic (input-pipe-starved, prediction/input-starvation.ts): ~5s of absent acks while frames are flushing — i.e. the self-heal did NOT work — emits one engine diagnostic per episode through the same rail as renderer-worker-silent: runtime log (Savi's getLogs) + one DM, explicitly telling her the scripts are not the cause and what to do. Reports ride the command outbox, so they deliver even while input is starved.
  • Model-placeholder preview billboards no longer shader-white-key the preview texture (createPlaceholderMaterial in three/extensions/models/placeholder-visuals.ts). MagicCDN now removes the preview background server-side (BiRefNet) before writing preview_image_url, so the texture's own alpha is the mask. The luma+saturation whiteMask heuristic — which also ate near-white, low-saturation pixels of the actual subject (white swords, paper, bones) — is deleted; previewAlpha is just preview.a.

Engine v5.0.8

Released June 8, 2026

  • Shadows now fit every graphics card the same way: machines with tighter limits (many Macs) keep full shadow quality at every tier instead of dropping to a single sun shadow. Secondary sun shadows in big worlds render slightly smaller on low and medium quality.
  • Your posted game is now truly pinned to what you published. Switching engine versions, editing with Savi, or saving work-in-progress no longer touches anyone playing your posted game — players keep running exactly the version you shipped until you hit publish again.
  • The automatic graphics governor now actually engages in heavy scenes: worlds that stream assets or compile effects continuously used to keep it switched off, so weak devices stayed at 14fps with full effects. It also now notices devices stuck around 25–35fps, not just total collapses.
  • Distant mountains stay on the horizon — big foggy worlds keep their silhouettes without the loading cost
  • Content Savi adds appears all at once again — no more piece-by-piece assembly
  • When an NPC's model can't animate at all (the asset has no skeleton or animation clips baked in), Savi now gets told exactly that — instead of your enemies sliding around in a T-pose with no clue anywhere about why.
  • Switching your world to nighttime no longer drops the frame rate the way it used to. The moon, milky way, and stars look exactly the same — the sky just stopped doing invisible work for every pixel, every frame.
  • Fixed a scary "Unable to reconnect" banner that could appear over a perfectly healthy game and never go away. If your connection is fine, the engine no longer cries wolf.
  • Traveling between areas rebuilds terrain fresh each time — simpler and more predictable.
  • Reverting to a previous version now fully resets the live world to that version — runtime leftovers from removed scripts no longer survive the revert. Objects a script had moved snap back to their saved positions, and script-written state is cleared to what the version saved. Players are never teleported or reset by a revert.
  • Attaching multi-part objects to a character's hands (or any bone) no longer makes the parts fly apart or swirl around before snapping into place — they ride the wearer from the first frame, in solo and multiplayer games alike.
  • Fixed a crash that could freeze the game (with an endless "Unable to reconnect" message) for everyone in the room when terrain decorations were written with missing or misshapen fields. Invalid decoration patches now fail immediately with a precise error, and clients skip any broken decoration entries instead of crashing.
  • Fixed visual effects from one place leaking into other places. Effects spawned by an object that removes itself (like an explosion left behind by a grenade) used to land in your world's default place and pile up there forever — swapping places could greet you with effects that were never spawned there. Effects now always live and die with the place they were created in.
  • Fixed objects from a place you just left occasionally staying visible in the place you arrived in. A network hiccup around the moment of travel could resurrect the objects nearest you as ghosts that rendered in every place and never went away until a full reload — most visible as a cluster of exhibits from the previous zone hanging in the air. Ghosts can no longer be created; leaving a place now reliably takes its objects with it.
  • Fixed a bug where ponds or lakes with foam or caustics could turn the whole world black (except the water itself) after turning water refraction off. If your world went dark with no errors, this was likely it — it renders correctly now.
  • Savi's changes now show up live for everyone in the room, every time — no more "I had to refresh to see it" moments.
  • Games that drive movement or aiming from scripts (setAxis) no longer freeze players mid-stride after a reload
  • Fixed a multiplayer bug where a brief server or network stall could leave a player rubber-banding for minutes — snapping back to an old position (and rolled-back health) about once a second. Recovery is now a single correction.
  • Games that drive movement or aiming from scripts (setAxis) no longer freeze players mid-stride after a reload
  • Savi now knows that bullets fired by your character should be spawned right where you pressed fire — in multiplayer they leave your muzzle instantly instead of trailing behind you while you move.
  • Fixed stripey/banded shadows that could appear on big flat surfaces when the engine steps lighting quality down to keep your game smooth — shadows now stay clean at every quality level.

technical notes

  • Pinned-publish live rooms (ledger #161, Rocket Romp): a LIVE room for a posted game now resolves BOTH its spec and its engine from the PINNED publish row — never the latest dev row. Kiln's iframe-context resolver consults the publish pin for new live-mode rooms (getPublishedEngineVersionForApp: game_publishes → game_specs.engine_version at the published version; slug rooms pin their own publish row), covering the play pages, the internal iframe-context route, prewarm, init-kernel, and edge routing through the one chokepoint. Active live rooms keep their registry-recorded identity (#119) unchanged.
  • Engine switches are dev-room events: the explicit-switch force-drain rails (engine-version PATCH route, Savi's manage_engine_version via the studio-chat drain-rooms route) are now mode-scoped to ['dev']. Pre-#161, a 4.6→5.0 switch mid-editing force-drained live rooms — effectively publishing work-in-progress and restarting everyone playing the posted game.
  • Publish is the ONE rail that moves live rooms: when the freshly pinned publish row carries an engine that PROVABLY differs from a live room's recorded identity, the publish flow drains that room onto the published engine (before the spec fanout, so reconnecting players boot engine + published spec together). Spec-only publishes keep the seamless in-place fanout; unknown-identity rooms are never restarted on a guess.
  • Auto-update (Jacob's ruling, verbatim: "it should only update on publish full stop. auto update is for the non /play (the dev) world"): engine auto-update flows to the creator's dev world only; players see the new engine when the creator next publishes. Spec reverts continue to carry engine_version by design — a revert reaches live, like everything else, only at publish.
  • Governor grace wedge (ledger #304): the budget rail demanded 10 contiguous wall-seconds of over-trip samples outside grace, and any single graced sample (collect backlog, JIT load in flight, first-sight shader compile — each re-arms a 3s grace) erased all accumulated overload evidence. Heavy scenes re-arm those graces chronically, so the exact sessions the governor exists for (sustained 14fps in a content-heavy world) never stepped at all — the drip-feed machinery disabled the safety net precisely when it was needed. The budget-rail axes (and the 60s boot-escalation floor window) now ACCUMULATE span-capped steady-state overload evidence: grace pauses accumulation (graced samples still never count — the #188 law holds), a genuinely calm steady-state sample still resets it, and a parked tab can't fake coverage (1s span cap, the guard's chronicMaxSampleSpanMs idiom). The mobile wall rail keeps its #6710-pinned contiguous behavior byte-identical.
  • GPU-bound interval blind band (ledger #304): the guard's presented-interval sensor (#199) was gated on the 40ms fallback budget, so a GPU-bound session between the degraded line (28ms) and the fallback line — e.g. a misdetected-iGPU desktop at 34fps — read as its tiny CPU time forever: invisible to chronic accounting AND to the budget rail whose documented trip line is 28ms. The sensor's floor is now the degraded line, with the wall rail's environmental-throttle signature (clean 2× vsync cadence, tight jitter, near-idle CPU — computed from the governor's per-frame EMAs and passed into each guard sample) holding the floor at the fallback budget so Low Power Mode and idle 30Hz displays still never read as degradation. Above the fallback budget nothing changes.
  • Governor telemetry drop (ledger #304): the sim worker's perf-rollup merge rebuilt the governor block field-by-field and dropped the three #193 effects-axis fields (effectsRung, effectsTransitions, bootSteps) — spawn.kernel.client.renderer.governor.effects_rung / effects_transition / boot_steps never reached Datadog from ANY session, so budget-rail engagement was unprovable in prod and the tuning data the #193 changeset promised never arrived. The fields now ride the rollup like the resolution/geometry ones.
  • Horizon silhouette ring (ledger #300): the #149/#261 extended-band streaming clamp bounded chunk streaming at the fog-saturation distance on the claim that fogged terrain is invisible — but fog-saturated terrain is fog-COLORED, not invisible: the skybox is never fog-extinguished, so fully-fogged mountains still read as silhouettes where they break the skyline. On clamped profiles, terrain visibly went missing (or popped) at the horizon. The clamp (and its perf win — it killed a 4,225-chunk boot storm) stays exactly as it was; a cheap far representation now carries the silhouette.
  • The ring: one client-local entity per heightmap place (terrain/horizon/<placeId>, new terrain/horizon-mesh component) carrying a 384×12 polar grid of REAL terrain heights spanning the annulus from just inside the clamped band edge (3 chunks of overlap) out to the radius the unclamped extended bands would have streamed. Heights are sampled in a jobs worker (terrain/horizon-build) through the exact chunk-build height pipeline — generator heightAt + marks + height fields + edits, via the now-shared createHeightmapHeightSampler (chunk builds verified byte-identical by the golden fixtures) — so authored mountains shape the skyline honestly. Total budget: 4,608 height samples ≈ a quarter of ONE LOD0 chunk's height pass, ~0.01% of the builds the clamp saves; zero chunk entities, zero colliders, zero decorations.
  • Renderer: one double-sided MeshBasicNodeMaterial annulus mesh per place whose colorNode IS the retained scene-fog color uniform (three/fog.ts) — fog mixes fog toward fog, so every fragment lands at exactly the color fully-fogged real terrain rendered, at any saturation, tracking fog-color changes as uniform writes with no per-frame sync. Outer skirt drops to the place's vertical floor so elevated cameras never see under the rim. Never raycast-bound, never in the shadow pass, exempt from the fog-equivalence object cull by construction (it's a plain mesh, not a model/decoration).
  • Lifecycle honesty: the ring exists exactly when the clamp actually cut bands (linear fog saturating inside the band edge, extended profiles, heightmap generator) and disappears when fog lifts or goes exp2 — exp2/no-fog/camera-far cases never cut, so pre-clamp parity needs nothing restored there. Staleness is input-keyed: definition revision/content, annulus-intersecting edits (buried-under-streamed-terrain edits are filtered out), composed height-field chunk versions, and a 2-chunk anchor recenter lattice all fold into the build key; rebuilds coalesce through a 1 s throttle and never stack more than one in-flight job. Standard profile (mobile/server) and the server streaming path are untouched.
  • The overturned premise's second consumer dies with it (architecture-audit scope addition): the fog-equivalence cull on model/decoration draws (geometry-budget.ts fogEquivalenceCullDistance + the models LOD-pass fog cut + the decorations cull-group maxDistance fog write) proved the fragment was fog-COLORED, not that skipping the draw was invisible — a fogged tower breaking the terrain skyline visibly vanished, exactly like the mountains. A sound test needs per-direction skyline knowledge no cheap per-object predicate has, so the cull is REMOVED, not gated (the ruling's escape clause): FOG_CULL_TRANSMITTANCE, FogReading, fogSaturationViewZ, perspectiveScreenCornerFactor (the geometry-budget copy), fogEquivalenceCullDistance, decorationFogCullUniformValue, readSceneFogReading, and the retained-fog revision staleness key are all gone. Distance-ladder shedding (cullDistanceScale, "too small to read") is unchanged, and the fog-aware capacity win lives where it's coverage-preserving: the terrain clamp + this ring. The old cull test is inverted and pinned: fully-fogged models keep drawing.
  • Content instantiation lands in ONE renderer frame again (ledger #299): deleted the #164 collect budget (chunked collect, carry queue, backlog bookkeeping). Its 758ms storm is fixed at the roots instead — sync pipeline compiles (#263's creation-time async-warm covers every instantiation-path material class), a redundant per-material-op packModelBatches(scene, null) in the same-signature recolor branch (O(n²/2) slot packs), and getEntityObject's miss-path name scan over scene.children (O(scene) per creating lookup). A 1200-entity single-frame storm's JS collect cost dropped ~2,870ms → ~13ms warm (600 entities: ~717ms → ~6.5ms) — quadratic to linear.
  • Consumers of the budget's backlog signal updated honestly: the hitch detector's collect_backlog cause is gone (compile/terrain/load rails remain), the frame-budget guard's #188 load grace reads real in-flight asset loads only, the asset warmer's idle gate reads compile starts + real loads, and the chronic CPU report no longer names an instantiation backlog.
  • Ledger #287 ("mantle STILL slides down hills") investigation: the mantle character controller's slope hold is proven to NOT slide on real heightmap shapes at three levels — CC unit (heightfield tri-mesh ramps 10–44°, zoo-like bumpy octave hills, chunk seams between adjacent heightfield colliders, walk/jump/release entries into the hold, real player motor + DEFAULT_CONTROLLER_CONFIG), full physics/step pipeline (real buildTerrainHeightfieldBodyConfig terrain-chunk payloads, replicated config threading incl. the codec-bit-7 minSlopeSlideAngle default lane, both engines), and the real stack (Session Lab zoo terrain highlands, 40° hill: mantle feet bit-frozen over ~37 s of idle while raw rapier creeps ~4 mm/s downhill and never stops). On every measured scenario mantle's hold is equal to or strictly tighter than rapier's.
  • New slope-hold-heightfield suite pins the real-heightmap shape the #246 box-slope suite missed: triangle-face contacts, internal-edge crossings, chunk seams, bumpy hills, landing/standing/release entries, the real motor shape (grounded resets vy, then vy += g·dt — not a constant pressed-down displacement), plus a raw-rapier parity block documenting rapier's own idle creep and cross-walk drift on the identical mesh. A slope-idle-terrain-chunk integration test runs both engines through physics/step on production terrain-chunk colliders so config-threading breaks can't pass-while-broken.
  • The one downhill motion mantle does have on tri-meshes — internal-edge contact normals slightly deflecting cross-slope WALKS downhill (≤0.19 m per 12 m walked at 35°, zero at idle) — is bounded by the suite below raw rapier's same-scenario drift (−0.21 m). No engine math changed in this commit.
  • New Session Lab scenarios hill-slide-mantle / hill-slide-rapier are the reusable real-stack A/B harness: zoo spec, updatePlace engine flip before place entry, highlands steep-spot scan, idle + walk feet-position sampling via room_exec.
  • Moving T-pose NPCs now report why (ledger #307): a model with no skeleton and no animation clips renders through the static batch path, where mixer channels are silently ignored — the sculpt stays frozen in its authored pose while the NPC mover drives the entity around (QA's draugr "actively chasing me, stuck in a T-pose"). The skinned path's model-clip-not-found diagnostic never fires for these models because no mixer root exists, so Savi reasoned from the silence that the clips were fine and burned a session-long debugging loop on correct channel code. A named mixer channel landing on a static representation (at model attach or on a later mixer write) now emits a model-not-animatable engine diagnostic — one report per entity+model, re-armed on model swap — naming the entity, the model, and the requested clip, and pointing at the asset: the bake produced no rig (?animations=... only keeps clips that actually exist), so the fix is rebaking with animations or swapping to a rigged model. Reaches getLogs() and the one-time DM like every client diagnostic; static scenery with no channels stays silent.
  • Night-sky per-pixel cost gates (Ledger #301). Switching a game to nighttime opens the sky node's uniform night gate, and every sky pixel started paying the whole celestial stack every frame — most of it provably wasted: the moon's two fractal mottling stacks (~7 octaves of 3D perlin) ran for every night pixel and were multiplied by the disc mask's exact 0 outside the tiny disc, and the two 27-cell star lattices ran the full site/shell/gaussian/tint/twinkle math for every cell and multiplied it by the one-hash existence step's exact 0 (most cells are empty at any authored density). The moon disc shading is now gated to the disc mask, and the star lattices gate per-cell work on the existence hash plus the exact-zero shell/footprint window — every skipped term was a multiply-by-zero, so the rendered night is byte-identical; empty cells now cost one hash instead of six plus two gaussians. Cuts the night sky's marginal per-pixel cost roughly 3–4× (background node and IBL capture both). WGSL structure pinned by sky-night-cost-gates.test.ts via named gate vars (moonDiscMask, starCellLiveBright/starCellLiveFaint); the dominance suite keeps proving the new branches trap no shared vars.
  • Post-ready runtime-worker errors no longer raise the reconnect banner (ledger #305): an uncaught exception in the client runtime worker mid-session was broadcast as a loading-state error, which kiln renders as its infinite-duration "Unable to reconnect" toast — QA's session carried a scary dead-end banner ("Uncaught TypeError: Cannot read properties of undefined (reading 'reduce')") over a perfectly healthy, fully playable game for the entire session, because nothing ever posts a recovering state after a one-shot exception. A worker ErrorEvent is not a death certificate (the worker's event loop survives uncaught exceptions) and is not connection state. The host's runtime-worker error/messageerror handlers now route through broadcastRuntimeWorkerError (loading-state-broadcast.ts): pre-ready it walls the boot exactly as before (a sim worker that dies during boot is a real boot failure); post-ready it posts nothing — the error stays visible through its console.error, which client-error-forwarding (#268) mirrors to the parent logger and the #284 recent-errors ring, and connection truth stays with the transport's own disconnected/ready posts. This mirrors the rendererReady gate the renderer-worker host has had since #206.
  • Removed the client place-travel terrain retention that ledger #261 added (deleted by ruling, ledger #302): the keep-last-left-heightmap-place mechanism in client streaming (CLIENT_PLACE_TRAVEL_STATE / retainWhenEmpty / retainLastLeftPlace), the build system's place-switch installed-output keep + inputs-hash re-validation path, the entity-keyed output delete it required (deleteClientActiveOutputForEntity), and both retention test files. Leaving a place evicts its terrain again after the normal keep-alive grace, and re-entry rebuilds — that cost is accepted; back-and-forth crossings are uncommon and the build path is fast. The default-place never-evict rule and the server's default-place-only rule are unchanged, and #261's honest-aspect extended-band view clamp (the real place-entry boot fix) stays.
  • Replace apply resets live state (ledger #296): revert_to_version / kiln revert re-feed live rooms with replace: true, but the room only reset ONE live-state class (the debug-day W3 atmosphere-override clear) before running a plain diff-apply — authored transforms whose authored value didn't change across the revert were skipped (updateObject's prevDef gate), and runtime-only TomeState keys were structurally undeletable (mergeRuntimeStateWithSpecDefaults starts from {...current}). Runtime-moved objects and patchState keys survived every revert while the spec rail stayed byte-perfect — the creator saw a revert that "didn't work" (enfeul / Muffled Static, dump eddd7a4a).
  • The fix plumbs replace through to applySpec (ApplySpecOptions): a replace apply gives EVERY spec object the snapshot-restore treatment — authored transforms re-applied over live values, TomeState reset to a clone of the def's state (runtime keys drop), and the session atmosphere-override clear now lives inside the replace branch instead of as a room-runtime special case. Players keep their session state (the player merge is deliberately untouched) — a revert never teleports or wipes connected players. Normal (non-replace) live-edit semantics are byte-identical, pinned by tests.
  • Both execution modes run the same branch: the flag rides the upserted TomeSpecValue (replication to multiplayer clients) and the tome.spec.push control message (singleplayer authority — without this, a revert in singleplayer reset nothing, since the client world owns the live state there). spec-sync only honors the flag on an observed revision transition: fresh joins land on post-restore replicated state and must not replay the reset locally.
  • Tool honesty (cf-studio-chat, same train): revert_to_version's diff line no longer says "(no changes)" when the spec already matched the target — it now states the live world was still reset to this version, because that's exactly the case where the user reverted to fix runtime leftovers.
  • Bone-attach seeding (ledger #308): attaching a multi-part rig to an avatar bone (setParent/attachTo with attachment, spawn({ parent }) + properties.attachment, or setting the attachment property on an existing child) left the subtree's World* as garbage — the root at local-as-world (~world origin) and the parts it carried at their pre-attach world coords — because the #160 attach-time compose explicitly skipped bone-attached subtrees and no per-tick solve on either side ever composes them. Every renderer frame until the bone-pose feedback first resolves (a cross-realm SAB round trip at minimum; model load / horde demotion windows in practice) drew the rig exploded across the world. composeAttachedWorldTransforms now seeds bone edges exactly like plain edges (parent-entity ⊗ local — the best baseline both sides can agree on), and all three attach mouths route through it; the renderer's attachment solve takes over from the seed the moment feedback resolves.
  • Client projection corruption for bone subtrees (ledger #308, the multiplayer lane): localTransformProjectionSystem re-derived Local* for bone-attached children by dividing their World* by the parent's World* — but the derivation invariant doesn't hold for them (World = bone ⊗ local, not parent ⊗ local), and the server-frame World* rows it divides are frozen at the attach-time values forever. Every recompose overwrote the authored hand-relative offsets with inv(parentWorld) ⊗ stale — an error that changes with every parent move/turn, so the rig parts swirled in giant arcs while the player moved ("disintegrating into a tornado") and only re-converged when a replicated Local* row happened to land last. The projection now skips bone-attached subtrees entirely: their Local* is authored truth (replicated rows + script writes), excluded from prediction compare like all hierarchy-child transforms.
  • Render worker no longer dies on malformed terrain decoration configs (ledger #312, dump 2979b175). patchTerrain commits merged terrain to the room's live spec immediately while kiln's persist gate rejects the mutation seconds later — in that window every connected client rendered the raw value. A pebbles item authored as { kind: "primitive", primitive: {...}, material: {...} } (no shape/color) produced the whole #312 crash family: .reduce of undefined (layer without items), .length of undefined (hashString(item.shape) with shape missing), and .hasAttribute of undefined (unknown shape fell through createPrimitiveGeometry's switch into finalizeGeometry(undefined)). On 5.0.7 each throw latched kiln's "Unable to reconnect" banner (the #305 lane). Three gates now: (1) patchTerrain validates the MERGED decorations subtree against DecorationDefSchema before touching the live spec and fails the call with the same issues the persist gate would report — Savi sees the error at the call site instead of a poisoned room; untouched legacy-dirty decorations don't block unrelated terrain patches. (2) The renderer sanitizes the replicated terrain/decorations component at its boundary (sanitizeTerrainDecorationsValue): structurally broken layers/items are dropped with a warn naming the path, valid ones render. (3) supportedPrimitiveFrom actually rejects unknown draw/primitive kinds (it was an identity function with a null-handling call site), and createPrimitiveGeometry names the kind in a descriptive error instead of propagating undefined geometry.
  • Touch access to the keyboard-only debug panels (ledger #324). F3 overlay sizes to the viewport on coarse pointers — width: min(50vw, 680px) reads at ~190px on a phone, no panel at all. New ?debug boot fallback rides the same kiln page-url → iframe forwarding rail as ?stats: truthy values open the renderer inspector at renderer-ready, ?debug=f3/netcode start the F3 overlay open (URL intent beats the persisted closed state). The F2 inspector header on coarse pointers documents both openers (kiln's hold-Home gesture + the url param). The kiln:render-inspector / kiln:debug-overlay parent-message toggles the gesture rides already shipped (#6742).
  • Runtime spawns (spawn/spawnFx) issued after the spawner entity was destroyed (destroy-self-then-spawn — the grenade pattern) now inherit the spawner's last known place instead of falling back to the spec's default place. The fallback parked looping fx objects in the default place's bucket: invisible where they were spawned, permanently rendering for anyone who traveled to the default place, never reaped (looping programs have no completion bound) and never unloaded (the default place never unloads). The ObjectAPI instance captures its entity's place at creation and refreshes it on every live spawn-place read (ledger #309, the visual twin of #256's place-less audio proxies).
  • AOI juice events whose source entity died between emit and process (emit-burst-then-destroy-self) now scope their proxy entities to the receiving player's current place — the same ownership rule #256 gave targeted/broadcast deliveries. Looping particle proxies from a dead source previously spawned place-less ("global"), rendered in every place, and survived every place-travel sweep.
  • The exhibit leg (ledger #309 reopened): the client's room-delta ingest no longer materializes entities from UPDATE rows. Update rows carry only changed components — never PlaceMembership — so spawning an unknown entity from one minted a place-less husk that the renderer's place filter treats as a GLOBAL: it rendered in every place and survived every travel sweep. A stray update row for a departed-place entity (a duplicated/late/replayed frame around a travel — the class observed alongside the container-restart and RPC-retry storms in the reported session) resurrected exactly the entities with rows in flight at the travel moment: the couple dozen exhibits the zoo's player script was re-labeling near the player. Entity lifecycle is now owned by create rows, reset blobs, and delete rows; update rows (including deferred pending-local-edit stashes flushing after a despawn) for unknown entities are dropped. Two deliberate exceptions keep materializing: terrain chunk rows (#285 client-owned lifecycle — edit baselines arriving before streaming are never lost) and event rows (a die-fast source's fire has no create row; its transient carrier holds no draw output and the juice layer place-scopes the proxies spawned from it). The spec-authored exhibits themselves always had honest residency — the ghosts were ingest-minted husks wearing their components.
  • Regression pins: render-plane fx teardown on place travel (ecs-sync → render channel → particles handler → backend), spawn-place residency for live/attached/post-destroy spawns, dead-source juice proxy ownership + sweep, stray update/event rows after travel (no husk in the world, nothing re-enters the render stream, steady-state updates unaffected), and a full-pipeline zoo-shaped travel harness (real server netcode + behaviors + spec updates + place cleanup against the real client runtime with prediction, real place-filtered spec-sync, and renderer ecs-sync — join, dwell with exhibit-label churn, travel, settle) asserting zero departed-place entities in the client world or render stream.
  • Foam/caustics water with refraction turned off blacked every opaque pixel in the frame — terrain, player, sky — while the water itself (and its foam) rendered normally, with a completely clean console (ledger #332, the "Still Water" black world on 5.0.7). The water material's depth pipeline (shore foam / caustics / depth tint) samples viewportDepthTexture, whose ViewportTextureNode.updateBefore interrupts the scene pass mid-frame with a copyFramebufferToTexture exactly like the refraction backdrop's color grab. But the #253 viewport-share registration — the post chain's signal to STORE the scene pass's MSAA color instead of taking the #228 transient discard — was gated on refractionEnabled alone. With refraction off and any depth feature on, the depth grab still ended the pass (storeOp discard threw away every sample drawn so far) and resumed on zeroed memory: everything drawn before the water resolved black, everything after (the water, drawn in the transparent bucket) rendered lit and normal, and no validation error fired because the sequence is API-legal. The trigger content was verified live in the game's spec: both terrain pond marks carry liquid: { refraction: 0, shoreFoam: 0.55, caustics: 0.25, ... } (Savi zeroed refraction mid-session) — shore foam and caustics keep the depth pipeline on while refraction off drops the registration, exactly the gap. The black silhouette player in the report is the depth test: depth stores through the break, so water pixels behind the player never draw and the zeroed player region reads as a silhouette against the bright foam.
  • Fix: the factory registers the material as a viewport-share consumer whenever depthPipelineEnabled (which folds refraction), not just when refraction is on — the registry's own contract ("every material whose node graph can trigger the grab registers") now matches the graph. Pinned red→green by the depth-pipeline registration cases in water-material.test.ts and an end-to-end msaa-store-discard.test.ts case that drives the REAL fork backend through a depth-destination mid-pass grab with the REAL foam-only water material deciding the flag: stored at the break, loaded on the resume, discard restored once the pond is disposed.
  • Fixed intermittent loss of live spec updates to running rooms (ledger #326). The room's stale-echo guard stamped the live TomeSpec with the version of its own persist, but that persist's content is built by kiln on the DB's latest spec — which can include a Savi save the room never applied. The stamp then made the room skip Savi's poke (and its own echo) as stale, leaving the live world and every connected client on the old spec until the next foreign save or a refresh. Stamping is now consecutive-only: a persist that lands past an unapplied foreign version leaves the stamp alone so the poke applies.
  • Room spec-mutation persists are now serialized per room, coalesced, and retried with backoff. Overlapping persists raced kiln's version slot, 500'd, and silently dropped the losing batch's mutations (observed in prod as applyMutations failed storms).
  • A replace poke at the room's current DB version now applies instead of being skipped as a stale echo — this is the resync kiln sends after rejecting a mutation batch, which previously could never take effect.
  • Perf ACTION reports carry the creator-consent rule JIT (ledger #297, after Savi deleted a creator's effect off a frame-budget park report). Every diagnostic that describes an automatic engine action on creator content now embeds PERF_ACTION_CONSENT_RULE (renderer diagnostics.ts — the ruling's three elements in one line: ask the creator first, prefer the smallest reversible change, never delete): the frame-budget fallback/park report (frame-budget-report.ts fallbackMessage), the renderer fx budget-cull report (particles.ts FX_POPULATION_CAP_MESSAGE), and the server-side fx cap-cull log (fx-reap.ts, both latch shapes — its pointer DM carried the #235 consent frame but the log body Savi pulls with getLogs did not). The park report's "Edit the script(s) to re-enable" imperative is gone — un-parking is described as the creator-consented path. The rule sits ahead of the variable-length park list so the rail's 500-char message cap can never truncate it away. Coverage is pinned by a classification test over the server's diagnostic-code allowlist (engine-diagnostics.test.ts): every code must either carry the rule in its minted body or sit in a justified exempt bucket — error-class diagnostics (compile/runtime/build failures, broken assets: the fix-iterate loop, not actions on working content), device-local adaptive-quality steps (#193, no creator content touched), and informational perf warnings (no action taken; their DM rides the #235 consent-framed pointer). The #235 consent frame on the hourly perf pointer DMs is unchanged.
  • Fixed programmatic input axes vanishing from input frames when their value is exactly 0: the frame encoder skipped zero-valued axes and the input Proxy then fabricated a 0 for the absent key, so script-side ?? fallback patterns were dead code and aim-coupled controllers (e.g. setAxis-driven movement) halted mid-stride after a reload. Programmatic axes now ride every frame once granted, including at 0.
  • Fixed the prediction cap-adopt rubber-band storm (ledger #333): when a hard baseline adopt finds the server's newest snapshot more than MAX_RESIM_TICKS behind the local clock (a server wall-clock stall — worker blocked by a long synchronous spec apply/exec, GC, host CPU contention — whose missed time the backlog clamp discards), the client now rebases its local tick onto the server timeline with the same RTT-based lead a join picks. Previously the adopt rewrote state but left the clock stranded in the server's future: every input frame landed too_far in the server's buffer (the server simulated an idle player), every fresh correction was over the resim cap, and the adopt re-fired each cooldown window (~1/s) until the 0.8x input-flow throttle ground the surplus lead away at roughly 4s of slewing per 1s of stall — felt as "keeps restarting, snaps me back to full health and a different position."
  • Hard baseline adopts are now counted in resimulation stats (totalBaselineAdopts / lastSecondBaselineAdopts) and surfaced in the debug-dump misprediction block — adopt storms were previously invisible in dumps.
  • Projectiles skill now teaches WHY spawning from the shooter's onInput is multiplayer-safe (predicted locally, uniqueId() mints the same id on both machines from the replicated per-owner seq, resim reconciles, unconfirmed spawns are despawned) and names the anti-pattern: a server-only manager reading player positions births projectiles ~RTT behind a moving shooter's muzzle. Mirrored as a combat-skill best practice. The cross-realm reconciliation contract is pinned by predicted-projectile-spawn.test.ts (same-id mint with zero coordination, one welded bullet through rollback+replay, server-unconfirmed predicted spawns cleaned).
  • Resident directional light slots (compile-census row 19 + the fallback-swap storm): shadow-casting directionals are unbatched and hashed into every lit material's lighting cache key (light.id + castShadow in ClusteredLightsNode.customCacheKey, identically in the legacy DynamicLightsNode), so adding/removing one — including the zero-light fallback sun being disposed when Savi adds the FIRST light to an empty world — rebuilt every lit material synchronously (the trace-verified 235–540 ms #317 mechanism). resident-light-slots.ts generalizes the #317 idiom to the light layer: a pool of DirectionalLight identities (1 sun + 1–2 plain by tier) mints at the primary-scene seam in syncSceneLighting, castShadow frozen true, and never leaves the scene. Authored shadow directionals lease a slot (the entity's visual IS the slot light; values/transforms flow through the unchanged machinery); release idles it — intensity 0, shadow.autoUpdate/needsUpdate false (the fork's ShadowNode.updateBefore gate ⇒ zero shadow passes), shadow.intensity 0, map shrunk to 4×4 — so light churn never changes the identity set the hash sees. The sun slot has two flavors: csm (a SunCascadeShadow rig wraps the resident light for its lifetime; new setIdle parks scheduling and the per-frame fit) and plain (legacy follow-camera fit); camera/mode gate flips re-lease between resident flavors instead of dispose-and-recreate. The plain flavor needs no camera and mints eagerly with the pool, so fitToView: false suns and perspective→ortho camera flips never mint mid-session (cost: one extra always-resident shadow chain on csm scenes). Named residual: the csm flavor's rig needs a perspective camera, so a scene that BOOTS without one (2D/ortho start) and gains cascaded sun shadows mid-session pays exactly one whole-scene rebuild at that mint — structurally deferred, at most once per scene. The fallback sun is now a lease on the sun slot (ensureFallbackLights drives slot values; sky-ambient/IBL suppression keys on fallback ACTIVE). shadow.enabled flips migrate the visual between the batched uniform-array container and a slot — castShadow never flips on a hashed light. Over-K shadow directionals demote to the batched container (castShadow=false: lit, unshadowed, lightResidencyDebugInfo counts them) and the highest-scoring demotee promotes when a slot frees; each demotion also reports once per entity on the engine diagnostic rail (new allowlisted code light-shadow-slots-exhausted — runtime log entry Savi reads via getLogs() + one-time DM, #297 ACTION classification with the consent rule inline), because a shadow-requesting light quietly rendering shadowless is undiagnosable from the canvas. Demotion is an assignment state, not an object lifecycle: the promotion gate resolves through the lease's own availability predicate (findIdleCompatibleSlot) over the same request the promote would make, so a demoted visual keeps its batched object across syncs until a compatible slot actually frees — a looser gate ("any sun flavor idle") admitted demoted csm-wanting suns whose lease then failed on the specific flavor, disposing/re-minting their object every lighting sync; the N-sync steady-state pin (zero identity changes, zero shadow disposes, diagnostic once per entity, exactly one swap when the ranking actually changes) holds the class. Light selection pins resident lights visible (leaving the light list would change the hash), batched directionals compete for the remaining cap; authored layerMask follows the same law — slot-backed lights keep visible=true + full THREE layers for the lease's lifetime and express a layer-hide as intensity 0 + parked shadow passes (the idle idiom), where it previously visibility-flipped the hashed identity (one whole-scene rebuild on hide, another on show). Leased map sizes clamp per role (mapSize is unhashed, so every clamp is a binding refresh, never a pipeline): csm sun cascades budget PER CASCADE at the tier's sun.mapSize; the single-map (non-cascade) sun — 2D/ortho cameras, fitToView: false, legacy lighting, low tier — budgets at twice the per-cascade size ceilinged at the 4096 default (low/medium 2048, high/ultra/legacy 4096), since one map covering the whole shadow range needs more texels than one cascade of several; plain slots cap at 2048. Flagged taste calls (render-output changes vs the unclamped 4096 default): plain slots at 2048, and the single-map sun at 2048 on low/medium. Storm pins in lights-static-pipelines.test.ts hold the key bit-identical across add/remove churn, the fallback swap (both directions), shadow flips, over-K demotion/promotion, selection oversubscription, authored layerMask hide/show on resident lights (csm rig park included), the both-sun-flavors boot + fitToView: false plain-flavor re-lease, and the csm fallback→authored→parked arc — plus the demotion diagnostic emission, the single-map sun budget, and the IES/projector residency contract (texture OBJECT identity rides the hash; content updates don't) for the deferred resident-spot surface.
  • Steady-state GPU validation sentinel (ledger #365, extends #249): the renderer worker now opens a short run of per-frame validation error scopes on a fixed cadence (6 frames every 5s), not only inside the post-recovery probe window. A validation storm that begins muzzled — the boot storm already burned Dawn's per-device uncaptured-error budget, so a mid-session pipeline break delivers zero uncaptured errors — previously never tripped the #187 burst detector and left the canvas flat grey for the rest of the session with an empty error rail (dump d7730358). Sentinel captures re-enter the existing classifier, so a silent storm now earns recovery #1 and from there the #249 escalation: probe window → recovery #2 → exhaustion → the player's reload wall, with diagnostics on Savi's getLogs/DM rail at every step.
  • Render-worker silence watchdog (ledger #365): the renderer host now stamps liveness on every worker message (perf samples ride at ~1Hz whenever frames flow) and judges sustained silence — 15s with the page visible, post-ready — as a dead/wedged worker. A browser-killed worker fires no "error" event, no device.lost, and no validation errors; before this it froze the canvas forever with zero player affordance and zero telemetry. The trip is latched once and raises the same three surfaces device loss does: a page-context console.error (rides the #268 iframe→parent forwarding into observability), a renderer-worker-silent engine diagnostic (new allowlisted code), and a loading-state error post (kiln's reload pill). Hidden/occluded time never counts — the worker's loop parks legitimately without compositor begin-frames.
  • Directional shadow bank (extensions/lighting/directional-shadow-bank.ts) — the resident-light-slots changeset's named follow-up, built: every shadow-casting directional (sun CSM cascades + plain sun flavor + all k_plain resident slots) now renders into ONE shared depth array + ONE transmitted color array instead of a per-light depth+color pair each. Texture bindings dedupe by texture UUID (TextureNode.getUniformHash → value.uuid), so the whole directional shadow system costs a flat 2 sampled textures in every lit shader — K-marginal 0, cascade-marginal 0 (ultra == high == medium). High-tier worst-case lit stack: 23 → 13 honest (low: 11); a 16-grant Mac (Safari-class grants, spec defaults — the #249 field-death class) now runs FULL tier shadow rows instead of folding to medium. Mechanism is the fork's own TileShadowNode addon pattern, zero fork changes: per participant the stock ShadowNode is kept and the INSTANCE is patched — depthLayer (array-aware PCF filters + transmitted tail; bakes into WGSL as a const, so layers are mint-frozen), setupRenderTarget returning the shared pair, renderShadow targeting the layer via setRenderTarget(rt, layer) (the PointShadowNode cube-face precedent) with the stock setSize DROPPED — the bank is construction-frozen per session (perf-program law 3); stray participant mapSizes are pinned back to the bank edge with a once-per-session warning. Built at the resident-pool mint seam before the first material build (shader structure changes only at session boot); cascade layers are reserved up front even before the csm flavor mints, so a mid-session first-perspective-camera csm mint never grows the array. VRAM rides the atlas #228 placeholder-boot idiom (textures boot at 4², grow to the tier edge on the first directional shadow render, sticky). Layer layout per tier (clustered): high 6 @2048, ultra 7 @4096, medium 4 @1024, low 2 @1024.
  • Idle/wake on resident slots is now flags-only: the 4×4 idle shadow-map shrink and resizeShadowRenderTarget died (a bank layer cannot resize — the flat array IS the K-marginal-0 cost); idle slots (and parked csm cascades) park their shadow matrix on a constant-UV projection so the shadow taps baked into every lit material stay on one cache-hot texel of the full-size layer. Lease/demotion/promotion, castShadow-frozen identities, K-fixed-per-session, and every lighting-cache-key/identity-churn pin are byte-identical — the bank touches textures, never the hash (light.id + castShadow; same texture UUIDs for the arrays across all participants).
  • DECLARED VERDICT DEVIATION (needs ratification, not discovery): the shadow-cost verdict's build-order #1 ruled the budget-K backstop "stays regardless — the fold does not replace it"; this build DELETES it instead. The pool's grant-derived K budget (planResidentSlotBudget, setResidentLightSampledTextureGrant, the min-sun spend order, the light-shadow-slots-budget-constrained diagnostic, rendererMaxSampledTexturesPerShaderStage) collapsed and died: bank-backed slots are binding-free, so grant-folding K post-bank saves exactly zero bindings — the backstop became a structural no-op, not a vestigial floor, and keeping it would be dead-but-armed code. Law-5 parity holds: pre-bank low-tier demand was 11 and post-bank low is also 11, so hostile grants below 11 land in the identical place before and after (pinned by the "hostile grant below even low's rows still degrades" test). sampledTextureDemand (quality.ts) rewritten honest: 8 material+IBL reserve + 1 cluster data + (atlas ? 2 : 0) + (shadow pipeline ? 2 bank : 0) — it now INCLUDES the pool (the omission was the #249 mechanism) and fixes the old comment that misattributed the atlas's 2 to transmitted doubling (the atlas pair is depth + slot-record DataTexture; atlas lights never had transmitted color). applyAdapterShadowCap's donor walk survives as the hostile-limit fallback only (sheds the atlas pair for grants in [11, 13); below 11 degrades to low's rows rather than dying — law 5 floor unchanged).
  • Demand-model scope (honest residual, pre-existing — not a bank regression): the flat-13 accounting covers the DIRECTIONAL system; shadow-casting IES/projector/custom-color spots keep three's per-light path (ClusteredLightsNode isSpecialSpotLight) and bind an uncounted depth + transmitted pair each, so two of those on a 16-grant rebuild the #249 validation-burst class outside the cap. Named follow-up: count authored per-light spot shadows into the demand at the worstCaseLitStackSampledTextures seam, or demote their shadows on constrained grants.
  • Teardown disarm on bank participants: the fork's stock teardown disposes through both target aliases (ShadowNode._reset → this.shadowMap.dispose(), reached from dispose() and the shadow-type-change branch of setup(); LightShadow.dispose → this.map.dispose()) — for an adopted node both alias the SHARED bank target, so any single participant's teardown would have destroyed every directional shadow in the session. Unreachable today (slots are session-lifetime, lights.ts only tears down non-bank rigs), but unguarded; adoptShadowNodeIntoBank now also patches _reset and shadow.dispose to detach the bank alias before the stock body runs (participant-owned resources still tear down stock).
  • Flagged taste rulings (per the shadow-cost verdict — defaults shipped, do not re-decide silently):
    1. Plain-sun map budget halves. The single-map (non-cascade) sun rides the bank at the tier's sun.mapSize edge — high 4096→2048, medium 2048→1024 (the previous ×2-ceilinged-at-4096 budget died with the per-slot targets). 2D/ortho sessions (where the plain sun IS the sun) feel it most; the alternative (plain sun off-bank, +2 bindings → 15 total) was ruled out as the default.
    2. Ultra bank = 7 layers @4096, ~flat VRAM. ≈896MB depth+color allocated once grown (vs ~512MB idle / ~704MB full-lease before) — accepted for binding-flatness on discrete GPUs; placeholder boot covers shadow-light scenes.
    3. Transmitted color array KEPT (tinted/transparent-caster shadows preserved through the bank). Colored shadows through the shared color array need a zoo parity taste pass; dropping the color array (13→12 bindings, −~448MB ultra) is the contingent follow-up ruling and needs a fork one-liner.
  • Shadow bias now derives from shadow-texel world size at WALKED lighting tiers (staging w30 field report: banding/acne on desktop — the one-ladder rebuild made lighting-tier-down rungs holdable by any device, so desktop sessions now run map sizes/cascade counts whose bias constants were tuned once for each tier's DESKTOP-era defaults and never re-derived; the shadow-bank verdict named "acne retune" as the known bill for map-size changes). The law everywhere: bias = baseBias × (texelWorldSize / referenceTexelWorldSize), where the reference is the desktop tier-default configuration each constant was tuned against — scale exactly 1.0 there (the HIGH-tier desktop look is byte-identical, pinned by tests; at ULTRA — the default tier for discrete-GPU/Apple desktops — the single-map sun and plain slots run the bank's 4096 edge against the flat 2048 reference, resolving scale 0.5: authored bias/normalBias and the 0.08 normal-bias cap HALVE, an acne-direction look change with no test pin — OPEN RULING: floor the sun-flavor scale at 1, or accept as the high-tier retune in the staging taste pass), proportionally more bias at walked/clamped configurations instead of acne. Per context:
    • Sun cascades (SunCascadeShadow.fitCascadeDepthRange) — already the law; untouched. Per-cascade bias/normalBias re-derive from each cascade's actual texel geometry, so walked configs (medium's 2×1024/100 m on a desktop) land in-regime by construction. New tests pin the derivation itself across the high/ultra references, the medium walked rung, half-size maps (~2× texel → exactly 2× bias), and cascade-count reduction (the near cascade coarsens and its bias follows; the far cascade's box is maxFar-dominated and matches across counts).
    • Resident plain slots + the single-map (non-cascade) follow-camera sun (lights.ts directionalShadowBias). The auto depth term was already texel-proportional, but the absolute pieces — the normal-bias clamp bounds (0.02/0.08) and any authored bias/normalBias — were frozen at the desktop-era reference clamp (2048 for EVERY non-csm slot, single-map sun included — referenceResidentShadowMapSize returns a flat 2048; the csm flavor rides the live edge by construction). A walked tier shrinks the live edge under the same world frame (low/medium edge 1024 — texels ×2 vs the 2048 reference with the cap frozen at 0.08, under half the needed normal offset on large frames → the field-reported striping); the absolute pieces now scale by the live-clamp/reference-clamp texel ratio, so the resolved pair at any walked config is exactly the reference pair × the texel ratio. The scale follows the CLAMP, not the tier: an authored map under every cap resolves identically everywhere. The csm sun flavor is excluded by construction (scale 1) — its bias never comes from the source shadow; the rig re-derives per cascade.
    • Local-light shadow atlas (ShadowAtlas._captureFaceState → resolveAtlasFaceBias, consumed by ClusteredLightDataNode slotParams). The depth bias was a pure constant (−0.0005 in [0, 1] depth units — range cancels against texel-world growth, leaving cell texel COUNT as the one untracked dimension) and the normal-bias clamp bounds were absolute. Both now scale by the configured maxSlotSize's ratio to the desktop-era reference (1024 — TIER_PRESETS high/ultra since lighting v2 #6602): a walked atlas (medium's 512, previously phone-only, now a desktop rung) halves every cell and gets 2× bias instead of half the tuned slack. The coverage-bucket cell shrink was tuned-in behavior at reference and is deliberately NOT in the scale — desktop-default output is byte-identical including contended atlases.
    • Tests: reference-config invariance (scale 1.0 → existing values byte-identical, literal pins), walked-tier scaling (half map → exactly 2× bias, including the cap-engaged and authored-value cases), cascade-count reduction scaling, resident-slot clamped-size scaling, and the pure atlas resolver law (light.test.ts, sun-cascade-shadow.test.ts, shadow-atlas.test.ts).
  • Deliberately out of scope: PCF radius (penumbra targeting, not bias — unchanged at every tier) and the engine-owned fallback sun's zero default bias (0 × scale = 0; pre-existing, not part of the tuned constant family).
  • TASTE PASS REQUIRED (Jacob, staging): the walked-tier look changes (banding/acne → clean, slightly more peter-panning headroom at walked rungs); the desktop-default look is pinned unchanged. Repro surface: a desktop session holding lighting-tier-down rungs (or a forced low/medium tier) over large flat receivers at grazing sun angles + shadowed local lights.

Engine v5.0.7

Released June 7, 2026

  • Games now automatically turn down expensive graphics on hardware that can't keep up — effects cadence first, then shadow distances, then bloom and resolution, and only on the struggling device. Same game, same content, smooth on more machines; the device remembers where it landed so the next session starts there.
  • When something goes wrong inside a game, the Spawn team can now see it and fix it faster — crashes and errors in your worlds reach our monitoring instead of vanishing into the void.
  • Fast place switching no longer leaves the screen frozen on a stale frame with graphics errors. A rendering hiccup during a place swap could previously wedge the renderer into a state where nothing new reached the screen until another effect kicked in — the frame now always presents to the canvas.
  • Effects no longer hitch the game the first time they appear on screen.
  • God mode effects are now dramatically cheaper — fire, rain, snow and friends run on the GPU, so you can scatter them around your world without the lag. They're also real effect scripts now: ask Savi to open scripts/effects/fire.fx.js and make it yours, and tune Intensity/Scale right from the chip strip.
  • Walking back into a place you just left is fast now — the world you built there stays warm instead of rebuilding from scratch, so door round-trips (cabin to wilderness and back) no longer freeze the game.
  • Big foggy worlds load lighter: the engine no longer prepares terrain your screen can't actually see past the fog.
  • Effects that mix several sprite textures — most legacy fire/smoke/sparks/rain presets and any effect using a texture pack — now run on the GPU like everything else. The last big class of laggy particles is gone.
  • The F2 inspector's Parameters tab now has a Tier control: see what tier your device detected and force another one to taste your game as a phone/tablet or at low/medium/high/ultra quality (reload applies it). The label always shows forced vs detected, so you know exactly what you're looking at.
  • Fixed the root cause of a rare mid-session flicker-and-repair (or, before 5.0.3, a permanent black screen) on some graphics setups — usually after resizing the window or entering fullscreen with refractive water or similar depth effects in the scene. The render pipeline no longer disagrees with itself about MSAA, so the repair never needs to fire.
  • Singleplayer mode no longer drops fps while moving on terrain games — the engine was building and tearing down the same far terrain ring about once a second, and now it doesn't.
  • World text with characters the font doesn't have — emoji, fancy symbols — now shows a small gap instead of a ?. Curly quotes, em-dashes, ellipses, and accented letters render as their plain-text equivalents, so "the keeper's door" finally reads right.

technical notes

  • Adaptive quality governor (ledger #193): tier selection was capability-only, so a device that ADVERTISES desktop-class WebGPU (2017 iMac → "ultra") got desktop shadows/MSAA/effects regardless of how it performed. The quality governor now has a third, budget-rail-driven effects axis that consumes the frame-budget guard's own windowed measurement (max of cpu / gpu / GPU-bound presented interval, with the #188 startup/compile/load grace) — no parallel sensor — and steps quality down under sustained ≥10s degradation past the 28ms line.
  • Ladder order is the locked #228 wave-2 ruling — effects before resolution: E1 sky-capture cadence + shadow refresh budget (invisible at rest), E2 sun/local shadow distances one tier down, E3 shipped-floor distances + bloom pyramid paused, then cross-escalation only: render scale (existing QUALITY_LADDER), then geometry rungs (existing GEOMETRY_LADDER). Every step value is a shipped lighting-tier preset row (one config surface, no parallel quality vocabulary), applied through the existing seams (DPR plumb, no-bloom post topology, geometry scales, plus per-frame identity-reconciled runtime knobs on SunCascadeShadow maxFar, the shadow-atlas render budget/fade distance, and the sky environment capture threshold).
  • Stability: sticky-down only in-session (no mid-game quality pops — re-promotion is cross-session), 25/28ms hysteresis band, 8s settle between steps, and steps never fire during load/compile storms. On constrained devices the shipped mobile wall-rail behavior is untouched; the budget rail drives only the effects axis there (strictly slower + higher-threshold, so mobile resolution always reacts first).
  • Persistence: the landing tier is stored client-side (spawn.adaptiveQuality.v1) keyed by a capability fingerprint (GPU identity + device class + browser major) so the next session STARTS at the landing instead of re-suffering the descent. Construction-frozen knobs ride persisted boot steps: a session that stays overloaded ≥60s at the full in-session floor makes the NEXT session boot with MSAA off, then with the lighting tier stepped down (froxel grid, shadow atlas, cascades, sky LUTs, fx arena all re-init cheaper) — the hardcawcanary manual fix, automated. A fully calm session relaxes one step for the next session; a fingerprint change clears everything.
  • Honesty: every step (and a reduced-quality boot) logs an adaptive-quality-step engine diagnostic — getLogs-visible, log-only (no DM; no new perf-notify category; the 1/hr pointer gate untouched); chronic frame-budget warnings now name the governor's current landing as context; F2's Budget row and the perf rollup carry the effects rung, transitions, and boot steps.
  • Client error observability (ledger 268): the game iframe now forwards uncaught window errors, unhandled promise rejections, and console.error calls to the parent page over the existing postMessage rails (spawn:kernel:client-error, client-error-forwarding.ts). The kiln host re-emits them through its Datadog browser-logs pipeline with origin:iframe — the iframe itself loads no observability SDK. The channel is bounded: max 20 forwards per rolling minute, identical (source, message) deduped for 30s with a suppressed count on the next forward, messages truncated at 1,000 chars and stacks at 4,000. Payloads carry appId/variantId (from __SPAWN_CONTEXT__) plus roomMode/engineSemver (from the iframe URL). Worker crashes ride the existing worker-browser-host console.error relays, so no in-worker capture was added. Old kiln parents ignore the unknown message type.
  • Container logger (leg 3a fix): logger.ts now resolves stage from SPAWN_STAGE ?? DATADOG_ENV and reads the DATADOG_SERVICE/DATADOG_SOURCE/DATADOG_LOG_TAGS env vars that game-container.ts has been passing all along. On the cf-edge path nothing sends x-spawn-stage, so every prod/staging container previously logged to Datadog as stage:dev — in the colorized dev format, shipping ANSI codes to the intake. Prod/staging logs are now JSON-formatted and honestly tagged (resolveLoggerIdentity + pinned tests).
  • Fast place swapping could leave the screen frozen on a stale frame while the console repeated GPUValidationError: [Texture "output"] usage (TextureBinding|RenderAttachment) includes writable usage and another usage in the same synchronization scope (ledger #262, /zoo place swaps). Pre-existing on every 5.0.x — surfaced, not introduced, by the 5.0.6 window.
  • Mechanism: the fork's PassNode.updateBefore has no try/finally around its nested renderer.render(scene, camera) — a throw mid-scene-render (place-swap churn is the throw factory; the frame loop's catch keeps the loop alive) skips the setRenderTarget restore and latches the renderer's sticky render-target state on the scene pass's own target. Subsequent no-look frames ran postProcessing.render() with no explicit target: the output quad rendered INTO the latched scene-pass target while its material graph SAMPLES scenePass.getTextureNode("output") — one pass attaching and binding the same texture, rejected by Dawn at encoder finish, so frames stopped presenting until a look-active frame (which sets its target explicitly) happened to heal the latch.
  • Fix: the no-look path now pins the canvas target explicitly — renderer.setRenderTarget(null) before postProcessing.render() (post-processing.ts), mirroring the look-active path. The quad never inherits ambient render-target state, killing the entire latch class regardless of which throw latched. The fork-side hardening (try/finally around the nested render in PassNode.updateBefore) goes upstream at the next fork bump — no fork respin here.
  • Pinned red→green: post-processing-latched-target.test.ts drives the real fork PassNode.updateBefore + the real engine post chain headlessly — constructs the latch (throwing nested render leaves the renderer aimed at the scene-pass RT, whose "output" texture the active output topology provably samples), then asserts the next no-look quad render is issued against null (pre-fix it inherited the latched RT — the conflicting pass verbatim). Doubles as the fork-bump tripwire for the try/finally.
  • Fixed runtime shader compilation on fx deck appearance (ledger #263). The GPU fx batch path minted a fresh storage node per population deck, and WGSL names storage buffers NodeBuffer_<node.id> — so every same-shape deck generated different shader text, missed three's program/pipeline caches, and compiled a brand-new render pipeline synchronously at its first draw. The storage node now carries a stable name (fxGpuRenderInstances), making same-shape deck WGSL byte-identical (pinned by fx-deck-wgsl-identity.test.ts for both batch paths).
  • Every brand-new fx batch (CPU and GPU) now rides the async material-compile queue at creation — not just the first batch of each blend × align shape — so a deck's pipelines are compiled off-frame before its first draw. The only queue skip left is the rebuild of a live, visible family (capacity regrow / arena window move), which is flicker-sensitive and now a true cache hit by WGSL identity. Regrows that replace a never-seen batch (a first burst bigger than the initial capacity, or a rebuild racing its own first compile) re-queue.
  • The fx-gpu spawn compute pipeline is warmed on the arena's first compute (zero-work dispatch, threads early-out on the empty spawn map) instead of compiling at the first frame something actually spawns.
  • God-mode Effects-tab prefabs (fire, smoke, dust, sparks, magic, leaves, fireflies, rain, snow, embers, mist) now place fx-arena decks instead of legacy particles emitters: arming the tool vendors a first-party scripts/effects/<kind>.fx.js into spec.scripts (skipped if the game already defines that path) and the placed object carries fx: { script, params: { intensity, scale } }. The legacy payloads were all GPU-ineligible (sprite.textures per-particle variance; sparks added a ribbon sink), so every placed prefab ran on the CPU particle backend — per-particle JS sim on the renderer worker plus one draw batch, one material, and a full dynamic-buffer re-upload per texture per frame (~55 batches for one of each kind). The new decks are faithful ports (same rates, lifetimes, motion, size/color/alpha curves) authored to be GPU-eligible: one texture per population, velocity-stretched sparks instead of ribbons, a two-population leaf mix.
  • effectIneligibility is exported from renderer/three/fx-gpu/backend.ts so first-party content can regression-test GPU-arena eligibility against the real routing policy.
  • Existing games are untouched: properties.particles specs keep working on the legacy path (its lowering/VM test corpus is frozen in engine/particles/__tests__/legacy-emitter-fixtures.ts), and already-placed emitters keep their payloads.
  • Acute frame-drop (hitch) telemetry, ledger #267. Existing metrics average frame times over windows, so a single 100–250ms spike — exactly what an fx compile on frustum entry, a place-swap collapse, or a god-mode prefab build feels like — was invisible, and DD percentile aggregation is disabled on most distributions. New HitchDetector (engine/renderer/hitch-detector.ts, pure state machine in the frame-budget-guard mold) measures the wall gap between consecutive RENDERED frame starts in the renderer worker and buckets gaps over the 50/100/250ms ladder (exclusive buckets: 50_100, 100_250, 250_plus; constants in one place).
  • Visibility robustness: a backgrounded tab is NOT a hitch. Two guards: the host's forwarded visibilitychange drops the gap baseline (notePause, same signal the quality governor consumes), and a 2.5s gap ceiling (mirroring the renderer's FRAME_LOOP_PAUSED_AFTER_MS contract) classifies occlusion parks — which fire no visibilitychange — as pauses, never hitches.
  • Attribution at capture, from existing rails only (no parallel sensors). Each hitch is attributed with the PREVIOUS frame's facts — the frame whose work and aftermath filled the gap: compile_sync (node-builder cache delta inside the render call — the #152 #46 counter's signal, now hoisted per frame), compile_async (compileQueued start, the guard's compile-grace source), terrain_ingest (per-frame voxel chunk geometry installs, hoisted and shared with the perf sampler), collect_backlog (#164 backlog count), asset_load (the warmer's realPendingLoads, reused from the frame-budget guard's 250ms sample so the manifest is never re-walked per frame), gc_hint (performance.memory heap shrink ≥ 8MB across the gap — Chrome-only, named heuristic), gpu_bound (gap > 2× the frame's measured CPU work — the guard's unexplained-interval idiom from #199), else honest unattributed. Phase tag startup (first 10s) vs steady — tagged, never suppressed.
  • Emission: counters, not distributions — aggregation-proof. Window counts ride the existing perf rail unchanged in shape: renderer worker 1s sample (RendererPerfSample.hitches, absent on hitch-free windows) → sim-worker 15s rollup accumulator (RendererRollupBlock.hitches, counts summed per bucket × cause × phase, capped at the 48-line tag space) → kiln perf-rollup route → DD counter spawn.kernel.client.renderer.hitch.count tagged hitch_bucket/hitch_cause/phase (route half ships as its own additive commit; the route strips the key until then — ordering-safe).
  • Debug-dump breadcrumbs: the detector keeps a session-cumulative worst-10 ring (timestamp, duration, verdict, raw facts; ~2KB JSON, size-bounded by test). Snapshots ride the rollup only on change (worstRing), the studio surface retains the latest (kiln/core/kernel/hitch-breadcrumbs.ts), and the debug-dump capture includes it as diagnostics.hitches — "it froze" reports now carry receipts.
  • Tests: detector buckets/visibility/pause-ceiling/attribution-precedence/phase/window-drain/ring-bounds (hitch-detector.test.ts, 17 cases), rollup accumulation + ring carry (renderer-perf-rollup.test.ts), kiln retention (hitch-breadcrumbs.unit.test.ts).
  • Fixed the place-entry terrain build storm (ledger #261): entering a terrain-heavy place could hold fps at <=5 for 30+ seconds while the client cold-built the full extended-profile desired set (2,400-3,250 chunks through the place's heightAt/materialAt generator on the 1-2 client job workers — ledger #192's mechanism on the place-ENTRY path).
  • The extended-band visibility clamp (#149) now bounds the screen corner with the client's actual render-surface aspect instead of a worst-case 32:9 display (fallback until the host reports a surface). A 16:9 desktop with linear fog far 380 streams band radius 19 instead of 28 — a 53% smaller desired set on extended-high.
  • Client terrain streaming retains the chunks of the last heightmap place the local viewer left (the default-place never-evict rule extended to exactly one more place), and the client build system's place-switch reset keeps installed-output records, so returning to a just-left place re-validates resident chunks by inputs hash (budgeted, zero rebuild jobs) instead of rebuilding the whole place. Voxel places are not retained (renderer bucket arena slots must free for the next place); server streaming is unchanged.
  • Multi-texture sprite sinks (sprite.textures) now ride the GPU fx arena (ledger #269): texture packs render as one batch sampling a texture_2d_array (per-particle layer in the reserved instMisc.y lane — no new buffers or bind-group entries), the array composited at runtime from each layer's source raster, refcounted per ordered pack, content-scale compensated. One new material shape per blend × align, warmed through the async compile queue at creation. Packs >32 layers stay on the CPU backend with an explicit stats().ineligible reason. This was the routing gate that kept 11/11 legacy god-mode prefabs and every textureIds emitter on the CPU path.
  • Worker-start hardening (ledger #281): the per-boot container instance id is generated in a try/catch — workerd forbids global-scope randomness and the wrangler Worker bundle shares this module, so an uncaught throw killed every isolate at start (local dev + any worker deploy). Bun containers keep real per-boot ids.
  • Debug-dump capture (ledger #284): every 15s perf rollup now carries a misprediction breadcrumb ring (≤50 rows/30s from the SAME MismatchTracker evidence the F3 panel reads — tick, entity, component, drift/push/skew class, first differing srv/cli leaf) plus an SP/MP identity block (execution topology + AOI player count). The kiln dump summary renders mode, engine, per-place physics kinds, the mispred ring, and a scrubbed ring of the last ~30 parent+iframe console errors up front.
  • Fall-through + adopt-storm fix (ledger #285, P0): three composing client bugs made a player fall through their own terrain and rubber-band violently forever ([terrain/chunk-rescue] firing thousands of times, remediation rebuilds never healing, prediction resync: adopted authoritative baseline walls with a frozen mismatchTick).
  • Terrain chunk entities are now locally owned everywhere replication touches them. Chunk entities are dual-owned by design — the client installs its own collider plane (slim PhysicsBodyConfig + never-replicated TerrainChunkColliderPayload + WorldFeetPosition) on the SAME entity ids the server replicates edit baselines through (the place-global TerrainChunkEdits expansion). Three paths treated every replicated-class component on a chunk as server-owned:
    • The projection-reset sweep (room-delta-ingest) despawned edited chunk entities wholesale, destroying the client's mesh + collider payload while the surviving install bookkeeping (same entity id) blocked the rebuild. Resets now reconcile chunks in place; chunks the reset no longer shows get only their stale edit baseline stripped. AOI-exit delete rows for chunks (edits cleared by a terrain regen — the ledger #93 family) likewise strip the baseline instead of despawning the client's chunk.
    • The resimulation authoritative-state apply (resimulation.ts) stripped ALL replicated-class components from a chunk in the rollback scope when the server had no row at the mismatch tick (un-edited chunks never replicate) — including the freshly installed PhysicsBodyConfig/WorldFeetPosition. The physics cleanup then disposed the realized collider: the predicted CC free-fell through its own ground, and every chunk-rescue remediation rebuild was re-stripped by the next resim. The apply now reconciles ONLY the chunk-replicable set (TERRAIN_CHUNK_REPLICABLE_COMPONENT_NAMES: edits + membership for writes; edits only for removals — predicted edits still roll back to server truth, preserving the voxel-extrude fix). Chunk entities are never despawned as strays, with or without their ClientEntity tag.
    • Chunk create/update rows are filtered to the same set on ingest, so a row carrying the server's own (delta-suppressed, create-time-stale) collider config can never clobber the client's locally built config/payload pair.
  • Hard-adopt now clears the projection-reset replay anchor (pendingProjectionResetReplayTick). A reset whose baseline was already older than the resim cap when consumed (ingest backlog, main-thread stall) re-anchored every runResimulationIfNeeded at the frozen reset tick — live input frames re-armed it each tick, so the client cap-adopted on every 30-tick cooldown forever (frozen mismatchTick, zero normal corrections: the felt adopt-storm flicker). The adopt is a wholesale baseline replacement at least as new as the reset's, and it clears the input tail the anchor would replay — the anchor dies with it.
  • Boot terrain collider gate (the 5.0.7 TTI fast lane) verified unaffected: the shared collider-gate module still requires the support chunk's realized, payload-matched collider before first unpause; the fast lane only re-prioritizes the build. The #285 episodes start mid-session, and the same class exists on 5.0.6 (no TTI engine half) — the gate change neither created nor gates this bug.
  • F2 Renderer Inspector → Parameters: manual tier override (Ledger #293). A "Tier" control forces any shipped tier preset — device render tiers (phone/tablet/desktop: governor geometry baseline, wall ladder, MSAA/shadow/terrain-pbr floors) or lighting quality tiers (low/medium/high/ultra: lighting/sky/shadow config, effects-ladder baseline). Persisted in spawn.tierOverride.v1 (replaces the undocumented spawn.lightingTier.v1 knob), applied at renderer init so construction-frozen settings honor it; the display is honest ("forced — detected: …") and mid-session changes label "reload to apply". Forced tiers pin the governor's baseline (no persisted boot steps, no landing-state read/write) while the adaptive emergency rungs stay live.
  • Rooted out the GPU validation bursts the #187 recovery rail was built to survive (ledger #289, fork patch three-0.184.19-spawn.3.tgz). A depth texture with no render-target anchor — three's shared viewport depth buffer, the viewportDepthTexture singleton refractive water samples — answered its sample-ness question from whatever renderer state happened to be current at call time, and the three artifacts derived from that answer (the WGSL declaration, the bind group layout, the cached GPUTexture) are each created at a different moment: a frame-top compileAsync sees no bound target and a 0-sample currentSamples while the GPUTexture was created inside the 4x MSAA scene pass. Nothing bumps the texture's version to reconcile a drifted answer, so a freshly-recomputed single-sample layout meets the cached multisampled GPUTexture and every submit fails validation ("Sample count (4) … doesn't match expectation (multisampled: 0)" → Invalid BindGroup → Invalid CommandBuffer) until the burst rail rebuilds the chain. The fork's getTextureSampleData now applies two rules at the single seam all three consumers share: once the GPU object exists it IS the truth (layouts and shaders describe the texture that will actually be bound, whatever is bound when they build), and before it exists the evaluation reads the render context being prepared — compileAsync routes through the same framebuffer-target logic render() uses but never sets the public render target — before falling back to public state, so compile-time and render-time answers agree. The #187/#249 recovery rail stays as the backstop for exotic topologies (live consumers across a runtime MSAA flip); in the engine's topology the burst class no longer exists. Pinned by a CPU-side behavioral test driving the real fork evaluation/layout/bind-group code (bind-group-sampleness.test.ts) that doubles as the fork-bump tripwire.
  • Hybrid-GPU adapter selection (ledger #294): every navigator.gpu.requestAdapter() in the engine now asks for powerPreference: "high-performance". Without it, dual-GPU laptops hand the renderer the low-power INTEGRATED adapter — a discrete-class gaming laptop ran the whole game on iGPU silicon (34fps GPU-bound in the zoo). The contract lives in WEBGPU_ADAPTER_OPTIONS (engine/client/device-perf.ts) and is non-overridable at the renderer chokepoint (createRendererBackend).
  • Detection now measures the GPU rendering uses: the device-limit probes (renderer-backend.ts maxTextureArrayLayers, webgpu-limits.ts sampled-texture/sampler limits), the lighting-tier GPUAdapterInfo fingerprint, the boot wall's adapter probe, and the WebGPU telemetry summary all pass the same options. The WebGL fingerprint probe (detectGpuSummary) requests a powerPreference: "high-performance" context for the same reason — a default-power WebGL context on a hybrid laptop fingerprints the integrated chip and the tier ladder grades the wrong GPU (an RTX-class machine tiered as integrated Intel).
  • The renderer-backend texture-array-layer probe no longer requests a featureLevel: "compatibility" adapter — the engine never sets compatibilityMode, so the real device is created on a core adapter; probing a compatibility adapter read different (lower) limits than the device actually grants.
  • Kiln's pre-game GPU readiness check (hardware-acceleration.ts) probes the same high-performance adapter so it vouches for the GPU the engine will render on.
  • AMD APU tiering (the confirmed #294 fingerprint — an integrated Radeon 880M active while the discrete RTX idled): "Radeon … Graphics"-named integrated Radeons (880M/780M, bare "Radeon(TM) Graphics", Vega APUs) now classify as integrated and land the high lighting tier; the bare amd|radeon match had classed every APU as discrete and handed it ultra (4×4096 CSM cascades, 4096 shadow atlas, 48 shadowed lights). Discrete Radeons (RX naming) keep ultra.
  • Fixed the singleplayer dual-streamer fight (ledger #286): singleplayer glue runs every server-only system in the client world deduplicated by system NAME, so terrain/chunk-streaming (server) and terrain/client-streaming both survived and fought over the shared terrain/stream/<place>/<key> entity namespace with disagreeing desired sets (client extended desktop bands ~2,600 chunks vs server standard ~700). reconcileExistingChunks made the server pass adopt the client's far-band chunks, evictUndesiredChunks despawned them after the 30-tick keep-alive, the client respawned them and queued fresh builds — re-fired on every player chunk-coord change, a ~1,900-chunk rebuild/evict annulus per second while moving (5 fps; standing still was stable).
  • In a singleplayer world ONE streamer now owns the namespace: the server streaming pass suppresses itself (isSoleClientStreamerWorld — client-mode world + singleplayer spec), and the client pass absorbs the server pass's single unique responsibility, the guaranteed LOD0 chunk set under physics anchors (dynamic bodies / character controllers / awake vehicles always get resident chunks + colliders, derived with the same gatherers and AOI resolution the server pass uses). The guard is client-world-scoped: the room container's server-mode world for a singleplayer spec keeps its streamer, and real multiplayer worlds are untouched.
  • Removed the server streaming pass's write-only ran-tick bookkeeping (markServerStreamingRanTick — no readers anywhere) and documented the glue dedup loophole at the seam so the next differently-named server/client system pair decides namespace ownership explicitly.
  • draw/text glyph resolution no longer substitutes the replacement glyph (?) for codepoints the font atlas doesn't cover (ledger #292). The default GeistPixel atlas covers exactly printable ASCII (U+0020–U+007E), so every curly apostrophe, em-dash, accented letter, or emoji in creator/Savi-authored text rendered as tofu ("Open the keeper?s door"). Resolution now runs per grapheme cluster (Intl.Segmenter, codepoint fallback), in order: the font's own glyph wins; uncovered typographic punctuation falls back to its covered ASCII equivalent (smart quotes → '/", en/em-dashes and minus → -, tab → space, prime marks, fraction slash); NFKD compatibility decomposition recovers what it can ("José" reads "Jose", … → ..., no-break/fixed-width spaces → space, ² → 2, all-or-nothing per character so nothing half-renders); anything still uncovered renders nothing — one zero-area, space-advance gap per perceived character (a skin-toned or ZWJ-composed emoji leaves one gap, not five), while zero-width formatting codepoints (ZWJ, variation selectors, bidi marks, controls) collapse entirely. \r\n, \r, and U+2028/U+2029 now count as line breaks instead of resolving through the glyph path.

Engine v5.0.6

Released June 7, 2026

  • 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.

Engine v5.0.5

Released June 7, 2026

  • Savi stops nagging about performance: she now hears about it at most once an hour, only when a game has genuinely been slow for minutes on end (never during loading), and the note explicitly tells her it's informational — she won't change or remove anything in your game over performance unless you ask her to.

technical notes

  • Perf notifications to Savi rebuilt to the consent-and-chronicity law (ledger #235). A new governor (tome/perf-notify.ts) owns every perf push: categories client-sim / server-sim / render, one DM per hour GLOBAL — in any circumstance (a render DM silences all categories for the hour), and the message is a fixed pointer naming the category and Savi's own tools (getLogs(), view_live_scene) plus an explicit consent frame: the report is informational only, and nothing in the game may be optimized, simplified, or removed to address it without the creator's explicit consent. Analysis never rides the push — it stays in the runtime log for her to pull.
  • Chronic-only triggers everywhere: the frame-budget guard's per-episode warning is deleted — a render DM now requires degradation covering >60% of 5 minutes of accumulated steady-state time (load/startup/compile grace never enters the window), re-arming hourly. The degraded line moves 22→28ms (~36fps of real measured work): ~43fps steady is "the scene running well" and stays silent, per Jacob's calibration. fx-cap breaches and server-behind episodes log richly but only DM on the same >60%-of-5-minutes coverage; a combat-burst spike culls and logs, never DMs. Fallback (parking) keeps episode semantics as an ACTION report, firing only when something was newly parked.
  • The 240-character DM clip is dead: engine-diagnostics delivered a truncated copy of the 500-char runtime log entry (the mid-sentence cutoffs creators screenshotted). One cap now — the DM carries the same full framed message as the log.

Engine v5.0.4

Released June 7, 2026

  • Mobile is now opt-in at the door: your game plays on phones once mobile support is declared on the box and the game runs engine 5.0.3 or newer. Undeclared games stay desktop-only — phone visitors see an honest notice (with a "Try anyway") instead of broken touch controls.
  • Skies cost almost nothing now — the same sunsets, the same drifting clouds, a fraction of the GPU. Cloudy skies that used to be the single most expensive thing on screen (especially on phones) now render from one texture lookup.
  • Multiplayer sessions stop randomly reconnecting when a world's dev and live rooms share a server — routing noise no longer restarts the game simulation, so live sockets stay up instead of dropping every player for 8-11 seconds at a time.

technical notes

  • The publish skill's platform-declaration guidance flips to the new play-door law: undeclared games are desktop-only (NULL no longer reads as works-everywhere), and declaring mobile: true only opens the door when the game's resolved engine is at or above the 5.0.3 touch floor. Savi is taught to check the engine version in her context and upgrade with manage_engine_version before declaring mobile (propose_covers platforms and declare_platforms both carry the rule).
  • Sky cloud FBM baked (ledger #234): the procedural sky's dominant GPU cost was its cloud coverage field — two fractal-noise stacks (up to 5+3 octaves, each octave a full perlin-lattice evaluation) running per pixel, per frame, over up to half the screen, in both the background node and the IBL capture, for a field that is a pure function of the cloud-plane coordinate. The field now bakes into a plane-space window texture (rg16f, sky-cloud-field.ts) and the per-pixel composite samples it with one tap. Everything that animates stays live per frame and is exactly the same math: wind drift is a UV offset (now an accumulated-drift uniform, so a mid-session wind change bends cloud motion instead of re-scaling its whole history and teleporting the field), and coverage/density/altitude/colors/sun/moon remain uniforms in the per-pixel lighting composite. The atmosphere itself already followed this fix shape (Hillaire sky-view/transmittance LUTs re-rendered only on sun/media change), so per-frame day/night cycles keep their per-pixel smoothness untouched.
  • Re-bake policy: never for the day/night cycle or any authored knob — only on first activation (whole window, that frame; the targets start zeroed = coverage 0, so an unbaked frame shows no clouds rather than garbage), when accumulated drift crosses half the window margin (~8 minutes at default wind; re-centered one band per frame into a back target, committed via texture copy so the sampled binding identity never changes and no frame samples a half-baked window — the overlap re-bakes to identical values, making a completed re-center invisible), and when the authored cloud altitude outgrows/undershoots the window (window spans a fixed multiple of altitude, keeping constant angular resolution). Trigger logic is CPU-pure and unit-tested (sky-cloud-field.test.ts).
  • The per-tier cloud octave knobs (cloudBaseOctaves/cloudDetailOctaves) are gone: the bake band-limits its octaves to the window's Nyquist rate, so the new cloudFieldSize ladder (low/medium 1024, high/ultra 2048) reproduces the old octave ladder by construction (1024 ⇒ the old medium 4+2, 2048 ⇒ the full authored 5+3) while frequencies a texel can't represent are dropped instead of aliasing into baked moiré. No tier pays per-pixel octave cost anymore.
  • WGSL structure pins extended: the only fractal-noise call sites allowed in the sky fragment are the night stack's three (starlight grain, moon maria, moon craters — all behind the uniform night gate); the clouds branch must tap the baked field + transmittance LUT, one tap each. A fourth noise site failing the suite means per-pixel cloud octaves came back.
  • Container restart roulette deleted (ledger #239): the front door re-propagated x-spawn-{variant-id,room-id,room-mode,update-slug,api-url,sdk-api-key,config-version} headers into GLOBAL process env on every request, and ANY change respawned the simulation worker — one container serving a world's dev + live rooms (different variant ids) flip-flopped continuously, detaching every gameplay socket with 1006 and an 8-11s reconnect (50-500 respawns/hour in prod since May 31; 9,012 room-flip events on June 6). SDK identity is now per-room state on the room runtime — seeded once from boot env, updated only via per-RPC SDKConfig; propagateSdkEnvFromHeaders and its restart rail are deleted, and NO config change can restart the worker (only /admin/restart and genuine worker death). The spurious cold-boot restart that threw away every container's prewarmed worker (650-1500ms) is gone too. Variant change on the bound room (local dev) gets an in-place room reset instead of a worker kill. Stray-room RPCs get the explicit room-mismatch refusal instead of silently hijacking the worker. Residual (named follow-up): job-worker SDK snapshot + 3 other boot-env readers keep the previous app's identity after a local app switch until the dev kernel restarts.

Engine v5.0.3

Released June 6, 2026

technical notes

  • api.preloadAsset(ref): creator vocabulary for asset warming — one method for every ref creators already write (sound paths, sprite/effect textures, model urls; kind inferred by extension, manifest fallback). Deterministic by construction: pure fire-and-forget hint with zero sim-observable state (module-level hint queue outside every ECS world; server/non-client sides no-op; resim replays don't re-hint; bad refs warn once via the mutation-warn rail). Warming runs the EXISTING machinery early: audio prefetch+decode into the clip cache; texture/model warms call the asset service's normal getTexture/getModel so later scene use rides the async compile-hide rails exactly like JIT arrivals — no parallel warm path.
  • Engine-automatic steady-state preload sweep: after the boot flood demonstrably drains (terrain build settled + ~3s quiet sim-side; collect backlog empty + no compile starts + no real loads renderer-side), the engine sweeps the applied spec + script literals for referenced-but-cold assets and warms them at idle priority (1/frame, 2 in flight) — backing off the instant any real load appears. Explicit creator hints outrank idle guesses; nothing outranks real loads.
  • No-WebGPU browsers fail fast into the designed Browser-Update-Needed wall: the client entry checks navigator.gpu before any worker spins up and broadcasts the error kiln's loading screen has always matched — the producer side of that contract had died in a renderer rework, so unsupported browsers hung on a silent spinner (a real user sat 30 minutes). The message contract is now pinned in BOTH apps' tests. The entry also fires an async requestAdapter() probe: navigator.gpu existing with a NULL adapter (blocked/ancient GPUs, some Electron hosts) was the same silent spinner one layer deeper — the probe costs nothing on the happy path and lands on the same wall.
  • getSpec is never place-filtered (the place-misdiagnosis cascade driver): the client's applied spec empties every other place's objects for the render path, and in singleplayer Savi's run_script executes on the client world — so her diagnostic reads answered from the filtered view and a healthy world diagnosed as "your objects are lost". Spec-sync now records the unfiltered merged spec at the apply seam (TomeUnfilteredSpecResource) and getSpec backfills filtered places' objects on read (live objects win id collisions; the kept place is never touched — its live array is the truth, including deletions). The render path is unchanged; server worlds are the identity; client and server sides of a mode:"both" behavior now read the same whole spec.
  • Singleplayer spec writes no longer erase every other place (the "hub M.I.A." driver — distinct from and one layer below the getSpec read fix): in singleplayer the client is the authority, so ObjectAPI mutations based on the place-filtered applied spec became the tracked truth — one spec write while standing anywhere (a portal createIfMissing, a dynamic-spawn promotion, an updatePlace) permanently emptied every place the player wasn't in, on every session, while the DB stayed fully intact. Now every mutation snapshot reads the whole-world view (readAuthoritativeSpec — memoized place-filter backfill), the spec-update system applies the place-scoped view but tracks the FULL spec, and the unfiltered record stays current across mutation bursts. Multiplayer clients and server worlds were never affected. Pinned against the reporting world's exact spec shapes.
  • Portal/teleport arrivals no longer get yanked back to the player template's spawn position: when the template feetPosition is terrain-relative, the terrain-install re-anchor pass teleported EVERY live player in a terrain-changed place to the TEMPLATE's x/z — and place-cleanup unloading an emptied place after each visit made every portal crossing a fresh terrain install and a fresh teleport-to-spawn. The pass now does only its boot-case job: players still standing AT the template x/z re-drop at the real height; anyone who moved is never touched.
  • Savi's proactive frame-loss watcher no longer false-fires during game load: the frame-budget guard now stays in grace while a real asset flood is in progress (collect backlog non-empty or JIT loads in flight; warm-hint loads excluded so idle warming can't hold grace forever). Content-heavy worlds load past the fixed startup grace, and warning Savi about "frame losses" mid-load was a false alarm she acted on. Genuine sustained jank after the load settles still trips it.
  • The renderer recovers from a GPU validation-error burst instead of black-framing until reload: on some devices a resize or GPU-process recovery leaves the post-processing chain's bind-group layout disagreeing with a cached texture's sample count, and every subsequent submit fails validation. The device-error handler now detects a sustained burst (>=3 matching errors in 2s) and rebuilds the post chain into a sample-consistent state at frame top, budget-capped (<=2/5min) with one diagnostic per occurrence.
  • The child-dwarfs-parent attachment volume warning is removed: parenting has nothing to do with size — a thin anchor carrying a big model child is the engine's own standard representation and the heuristic fired on it. The escaped-child position check (world coordinates passed as parent-relative offsets) stays.
  • Savi prompt: full appetite-over-avoidance rework (shame framings removed, a frame for receiving user frustration, prohibitions converted to their complements, private-lexicon guard) — ships with the chat deploy.
  • UI render faults loudly when an inline handler calls a custom render-defined window.* global (e.g. onclick="window._myHelper()"): the render runs in the worker, the click fires in the main page where only sendAction/sendAxis/dispatchUIEvent are installed, so a render-defined global never crosses the thread boundary and the button silently does nothing. reportCrossRealmHandlerFault (worker-controller) scans rendered HTML each frame, dedupes by script-ref + global name, and emits a ui.handler fault naming the offending global and the fix — covers both player HUDs (ui) and creator/god-mode tabs. Creator-tabs and game-ui skill docs corrected to teach calling sendAction/sendAxis/dispatchUIEvent directly from inline handlers with the payload inline.
  • God mode now enters in singleplayer games. god-mode.toggle was a server command, but a singleplayer client IS the authority — its server mirror is never seen and its state deltas are dropped wholesale, so the command mutated a world the creator couldn't see and god mode silently never entered (it worked in multiplayer, broke the instant a game was flipped to singleplayer). The authoritative enter/exit logic is extracted into a transport-agnostic toggleGodModeForPlayer(world, playerEntityId, spawnInput, emit) core; the server command path resolves the player from the requesting client and calls it (multiplayer unchanged, byte-for-byte), and the singleplayer client now dispatches that core directly against its local world so TomeGodMode lands where the client's transition reads it.
  • Fixed the post-reset oplog window throwing OplogBuffer: no buffered ticks every tick. Every reset snapshot (join / place travel / reconnect / resync) lands via resetToStateOwned, leaving the client's server oplog base-only — baseTick set, zero buffered entries. getAuthoritativeWorldFrame guarded with isEmpty() but called getNewestTick(), which disagree on exactly that state, and the throw aborted local-transform-projection before its dirty-set clear so it rethrew every tick (unboundedly in quiescent rooms — the ~10-minute error clusters). Visible artifact: parented children collapsed onto their parents until the next real delta. Now the base snapshot is consumed as the authoritative frame; children project correctly on the very tick the snapshot lands. Same-class latent bug fixed in the debug-only ghost renderer.
  • Frame-budget guard gains a presented-frame-interval signal — the GPU-bound sensor it was structurally missing. cpuMs (rAF callback time) stays small when GPU backpressure parks the worker BETWEEN callbacks, and gpuMs has been 0 in prod since GPU timestamp timing went inspector-gated — so devices continuously over the 40ms fallback budget read as ok forever. The mean presented-frame interval (from the existing 250ms renderer-stats sampler) now counts as a GPU-bound overrun when it exceeds the fallback budget AND 2× what measured cpu+gpu explain, escalating through the existing warn/park path. 30Hz displays, vsync waits, and healthy 60fps devices sit under the gate; load/compile graces suppress it; a resumed hidden tab drops the in-progress stats window. This is the sensor for the adaptive quality-tier governor — governor policy still awaits ruling.
  • Mantle ↔ ECS bridge pre-flip cleanup, bookkeeping-only (pre/post end-state bit-identical on a multi-place vehicle scenario): the per-place per-tick whole-world body re-scan is replaced by a subscriber-maintained per-place index that preserves entitiesWith lexicographic order (body creation order — row assignment — unchanged; pure function of the same-tick world, resim-safe), and the per-tick vehicle wheel string fingerprint is replaced by wheels-array reference identity + structural compare on ref churn (value-equal churn keeps the live vehicle, real edits rebuild exactly once).
  • NPC systems stop billing every game every tick: the noise rail is fully off unless some behavior actually compiled an onNoise hook (composeBehaviors no longer fabricates truthy wrappers; every registration path keeps an anySpecHasOnNoise flag truthful), players come from a subscription-maintained tag index; nav-grid invalidation is change-driven via component subscriptions (the per-tick fnv1a fingerprint over every primitive is gone; overlay reads no longer poison the shared cache); NPC LOS rays route through the per-place octree with an AABB-reach high-water query bound (provably a candidate superset; octree-vs-full-scan equivalence pinned).
  • Repeated chunk-rescue now REMEDIATES instead of containing forever: a repeat-fire means the firing side simulates against a stale/missing support-chunk collider, so noteRescueFired requests that side's collider rebuild (server: artifacts cleared + dirty-marked past the inputsHash short-circuit; client: the re-request drain), debounced per chunk per 10s window, never from resim replays. Player-support chunks get a priority lane in the server rebuild queue (numericPriority 2, above the streaming-ring band) so the broken-support window shrinks even under load.
  • Quantized physics reads refix: getVehicleSpeed's replicated lane (the primary path for real vehicles) now snaps to the velocity read grid like every other lane (the raw read leaked sub-epsilon predicted-writeback drift into script state); quantizePhysicsRotation canonicalizes hemisphere before snapping (q and −q collapse to one bit-identical representative, −0 folded) — and the changeset record is corrected: velocity reads snap for ALL body types deliberately (kinematic linVel is not provably script-authored), with the sub-grid read-modify-write freeze documented (ramp from a script-state accumulator instead).
  • Sibling spec writers swept onto the authoritative whole-world read (the rest of the hub-loss family): the behavior/blueprint/brush property setters, resetTomeWorld, and the bounds-metadata persist all still based mutations on the place-filtered resource. All now read through the shared spec-read module; reset mirrors the spec-update seam (place-scoped apply, full-spec tracking); specUpdateSystem no longer trusts any requested spec as whole-world — it backfills through the unfiltered record first, so future filtered-based callers fail soft.
  • Players never carry terrain anchors (the #186 refix): the boot re-drop routed through a setter that PLANTED a terrain anchor on the player, so the next terrain re-apply teleported since-moved players back to the template x/z — and enterPlace's {x,z} spawn points armed the same anchor on every portal crossing. syncTerrainAnchor is now remove-only for session-owner entities; the spec-apply anchor pass and runtime-sculpt reanchor both skip players, so anchors planted by pre-fix sessions are inert. Object anchors untouched.
  • Removed terrain.rendering.textureTilingMode — a dead knob plumbed end-to-end but consumed by zero renderer code (setting it produced a rebuild to byte-identical output). Schema, types, interpreter defaults, resolved-config production, zoo authoring, and Savi's generated docs all dropped; non-strict parses strip it from existing specs.
  • Room heal-by-flip → heal by full re-apply: the hydration retry's ready-flip could admit players into a partially-applied room whose clients never receive the spec (the spec push was in the skipped tail). The retry now falls through to a full re-apply (the staleness guard makes a healthy room a no-op), degraded admission survives only as the last-resort catch path, and a join that TIMES OUT now closes the socket with the retryable 4430 code — previously timeouts closed nothing and stranded clients at Loading on an open socket.
  • The GPU validation-burst recovery rail's own telemetry now lands: its diagnostic code was missing from the ingest allow-list (dropped at the door), recoveries report per-occurrence via a session-monotonic ordinal, and budget-exhausted bursts emit one explicit diagnostic instead of going silent.
  • The no-WebGPU/null-adapter wall is sticky at the producer: one loading-state module latches the fatal verdict so a later boot-progress broadcast can't overwrite the wall (the host page's duplicate broadcaster routed through the same producer), and post-ready transient probe failures can no longer raise a false disconnect toast.
  • God mode's first-entry orientation DM rides the DM-forward rail, so it reaches Savi in singleplayer too (it read a server-only notifier resource directly — silent no-op on client authorities).
  • Object-preview renders are serialized behind an in-flight guard mirroring the scene-view lane (concurrent previews re-parented the shared light rig out of each other's scenes mid-compile — Savi's preview eye rendered sunless), with wedge abandonment previews never had and a throwaway-rig backstop.
  • Cross-place object-id collisions resolved with deterministic place-scoped runtime identity: the spec's object namespace is per-place but runtime entity ids were global, so co-resident places fought over the same id — the second place's spawn became an update that yanked the entity to the last-iterated place, and place unload destroyed it for everyone. Runtime identity now derives from the whole-world spec as a pure function of spec CONTENT (sorted place keys — merged-spec key order differs across realms after a place delete+recreate): the first place authoring a bare id keeps it (unique ids never change), every other copy runs as placeId:objectId, the established instance namespace the wire already speaks. Authored specs untouched; filtered-apply seams record the unfiltered spec pre-apply so both realms derive identity from the same view; an owners cache keeps the previous spec's runtime ids stable across edits.
  • Interaction prompts anchor at the source's world-space bounding-box top + headroom (was feet + a hardcoded height guess — mid-chest on any real humanoid); rotation, scale, real GLB bounds, and authored child assemblies all bake in via the existing world-bounds machinery; proximity still gates on distance to the object's feet; entities without renderable bounds keep the legacy heuristic until bounds arrive.
  • Cross-account player-state bleed in multiplayer closed: persistence converted ANY player patchState into a patch on the single shared spec.player.state template (identity discarded, last-writer-wins) — a relog landed you in another player's character. Persistence now gates on networking mode: singleplayer keeps template persistence byte-identical (the solo client IS the template's owner), multiplayer drops live player-state from spec persistence explicitly (durable per-player state belongs on the storage:* rail; the live sim is untouched — these are persistence-only filters). Secondary: multiplayer per-tick player patchState no longer enqueues a spec save per tick (~500 saves/min in the trigger game, feeding a 947ms tick melt).
  • Audio voice-pool release invariant: only the natural-end path ever freed a one-shot's voice slot — culls at the 32-voice ceiling, immediate stops, GC on place travel, same-key retriggers, stops of pending starts, and never-loading clips all leaked the slot forever, so long sessions went permanently silent. Every exit path now releases exactly once (per-voice latch, identity-checked onended, transfer-on-retrigger); terminal load failures release their slot and stop the per-frame refetch storm; completion-driven despawn is ownership-gated so finished clips on real game objects can no longer delete the object client-side.
  • Singleplayer behavior-driven parent root-slide regression pin: the 5.0.0-class tear (net-ingest spec mutations composing children against uncomposed parent frames) is pinned by a full integrated test that fails on the 5.0.0 launch tree and passes since 5.0.1 — it can never silently return.
  • Encoder-death rides the device-lost rail (ledger #217a): "unable to make command encoder" (GPU-process memory starvation, classically a refresh booting into the old page's undisposed device budget) now classifies onto the existing reload wall with an allowlisted diagnostic; pagehide(persisted=false) disposes the renderer worker through to the fork's device.destroy() (the old synchronous terminate discarded the queued dispose — destroy had zero reachable callers on any exit path); latchFatalRendererError makes pre-ready boot errors sticky against racing progress posts (closes #206); {kind:"ready"} moves to the first PRESENTED frame with a 10s watchdog.
  • Mobile triangle budget (ledger #217b): the device-tiered geometry budget lost in the Golden Turd deslop returns as a geometry axis on the #6710 quality governor — the hysteresis machine extracted into a reusable GovernorAxis (resolution ladder is instance one, byte-identical), triangle EMA judged against per-tier envelopes (phone 1.0M / tablet 1.6M / desktop 8M diagnostic-only), aggressive G0–G3 ladder (decoration thinning → earlier LODs → tighter far-cull), cross-escalation from the resolution floor. Never shed: sim/physics, skinned visuals, the near field, authored cameras, text/UI.
  • Always-on fog-equivalence cull (the #149 pattern, ε=0.5% transmittance): models and decorations past DrawFog saturation are skipped — invisible by construction; fog clears un-cull via a revision staleness key. F2 gains a Budget row; geometry rung + triangle EMA ride the perf sample.
  • Game-UI style delivery no longer depends on the Referer header (ledger #222): WebKit caps Referer to origin inside cross-site iframes, so every Safari/iOS player 404'd the Tailwind runtime since the cf-edge cutover and saw raw unstyled markup. Assets resolve via document.baseURI against the hash-qualified engine root; the kernel adopts cf-edge's pre-injected runtime when its src matches, replacing stale pre-injects.
  • Sprite frame/time are render-clock state (ledger #225): the mode:"client" sprite animation system advances draw/sprite frame/time every render frame while the server never animates — the detector booked that by-design divergence as drift every tick on every animated sprite. correction:{mode:"snap"} — the declaration the #6673 sweep set on mixer/text/tween but missed on sprites. Compare-side only; authored writes still replicate; the sprite clock stays rollback-immune (pinned).
  • Phone tier floors (ledger #228a): shadows off and pbr terrain off on the phone class as DEVICE_RENDER_TIERS rows — shadow passes never construct (CSM rig skipped, atlas never allocates), the terrain pool compiles the existing no-PBR variant (WGSL-pinned: zero PBR rows/NRO chains/parallax in the phone fragment). Desktop byte-identical by pin; tablets keep single-sun + pbr.
  • God-mode flight parity (ledger #223): bone-attached subtrees leave the prediction-compare envelope (the renderer owns their drawn pose; the server never composes it — every delivery tick booked a srv=undef push refusal and a full rollback+resim, the storm pump); replicated state bags are wire-safe at the schema level (non-finite numbers store as the JSON null both realms decode; the god behavior's -Infinity tap sentinel became an explicit null, killing the quantized tick-zero double-tap collision).
  • Render-channel string table (ledger #227): the u16 intern ceiling is soft — string definitions ride the frame stream itself (STRDEF/STRRESET) with generational eviction at any frame boundary (the old reset required a fully-drained queue: append-only under backpressure), and juice-client's fifteen counter-suffixed id mint sites (oneshot_sound_N every footstep) now recycle per-prefix id pools bounded by peak concurrency. The overflow-replay machinery is deleted.
  • Six press/platforms tools collapse into publish() (ledger #230): one action-discriminated tool on the mod() pattern, per-action schema in the loadable publish skill — ~3,950 chars (~1k tokens) off Savi's always-on floor every conversation. Old tool names survive only as action literals; replayed conversations with dead tool calls pass through proven-by-test.
  • God-mode duplicate surface (ledger #232): the draw/placement/attach-pick takeovers now drop the published action panel the way brush always did — duplicate shows exactly one control surface (the ✓ PLACE/✕ chips) while the copy is in hand; the copy lands selected with its own chips. Fixes desktop's diamond cluster through the same resource.
  • Empty-frame floor, wave 0 (ledger #228): the frame-top clear double-present is dead (a hidden second 4xMSAA RGBA16F target cleared then painted over the canvas, ~25MB/frame of nothing); resolved-never-sampled MSAA color stores discard on TBDR (opt-in fork flags, proof-commented per attachment); the output topology defaults no-outline with lazy slot RTs (mobile slots 4→2); the local-shadow atlas allocates on first real caster; GPU timestamp measurement only runs with the F2 performance tab open.
  • Client-writer guard (ledger #226): a mode:"client" system declaring writes to a replicated compared component faults at registration; a dev/test write-site guard attributes every actual write through the execution-context stack (interpolation/renderPrep compared writes are structurally unmirrorable — the theorem all three storms shared); sustained push-delivery refusals emit one allowlisted prediction-push-refusal-storm diagnostic; the compare envelope is extracted to one module the mismatch detector itself consumes.
  • Datadog client telemetry restored (ledger #231): the networking deslop deleted the websocket/network rollup chain — 24 websocket.* + 6 network.* series + 5 startup phases re-homed on ClientRoomRuntime/runtime-worker seams with the same names (dashboards intact), the kiln ingest route restored verbatim. Prod only looked alive because pinned ≤4.x engines emit through a pre-deslop kiln deploy; the next deploy would have flatlined ~45 series silently.
  • Input-mode self-heal (ledger #233): a lost Tab keyup (e.g. Option pressed mid-hold — handleKeyUp skipped alt-flagged Tab keyups) left the engine in tab-held forever, eating digits/Space as parent shortcuts while WASD worked; kiln only re-sent overlay state on change, so nothing healed it. Now: stale non-game mode + eaten gameplay keys >4s → state-refresh request; kiln answers with current truth and re-broadcasts on focus/visibility; no answer in 1.5s → honest fallback to game mode. Active overlay use never flickers.
  • Engine diagnostics off the god-mode costume (ledger #235): the single DM rail stamped every notify_dm into the god-mode-activity wrapper — fx-cap and frame-budget warnings arrived in Savi's context costumed as creator edits, unlatched. Producers now tag source: god-mode (edit narration only) vs engine (new engine-report batch, never wrapped); fx-cap latches with an accumulating 5-min cooldown; frame-budget fires once per episode with a 10s recovery sustain; chat adds a fingerprint backstop for consecutive dupes. Chronic-only/1-per-hour-per-category tightening per Jacob's ruling follows.
  • Clarity over flair, wave 1 + the DPR lift (ledger #228): phone lighting tiers' maxPixelRatio 1→2 — a 3× phone renders 44% of native pixels instead of 11% — paid for by phone-class trims: MSAA off, the UNAUTHORED look bloom default resolves to 0 (any authored bloomStrength is honored everywhere; a seeded script default exactly equal to the platform default stays unclaimed), cloud octaves 2+1, biplanar slope projection compiled out with outward-skirt FrontSide pools (2-candidate blending stays; chunk geometry/colliders untouched). Desktop pinned byte-identical at every seam; F2 Budget row reports the active floors.
  • New for builders: ask Savi to preload a sound or effect before its big moment — until now everything loaded on first use, so the first gunshot played late and the first explosion rendered textureless. Now the first one comes out clean.
  • Worlds also warm their own sounds and effect textures quietly right after loading — first-time effects just play clean, no script changes needed.
  • If your browser can't run Spawn's graphics, you now see a clear "browser update needed" card in about a second — instead of a loading screen that never ends.
  • Savi can now always see your whole world when diagnosing, so she'll never again mistake a far-away area for missing.
  • Fixed a serious bug in solo worlds with multiple areas: making any change while standing in one area could make every other area look permanently empty (your data was always safe — the world just stopped loading it). Areas now stay exactly where you built them.
  • Doors and teleporters now drop players exactly where you aimed them — previously, in worlds where the player spawns on terrain, every trip through a portal could fling the player back to the world spawn point instead of the door's destination.
  • Savi won't flag performance problems while your game is still loading its assets anymore — she waits until things have settled, so a normal loading hitch no longer makes her think the game is slow.
  • Fixed a rare crash where the screen could go black and stay black mid-session on some graphics setups (usually after resizing the window) — the engine now repairs its render pipeline on the fly and keeps going, with at most a one-frame flicker.
  • Buttons in god-mode panels and custom HUDs that quietly did nothing now tell Savi exactly why — so she wires them the way that works the first time.
  • God mode now works in solo worlds. Building a singleplayer game and hitting the God Mode button (Tab+G) used to do nothing — now it drops you into build mode the same way it does in multiplayer.
  • Attached and parented objects no longer pile onto their parent for a moment after joining a world or traveling between areas.
  • The engine now notices when a device is drowning in graphics work, not just script work — sessions that quietly ran at 10–25fps on GPU-limited hardware now get the same automatic warn-then-recover treatment heavy scripts always did.
  • Worlds with NPCs spend less time thinking about nothing — guard hearing, pathfinding, and line-of-sight now only do work when something actually changed or can actually hear.
  • Falling through the ground and snapping back in a loop now heals itself: the engine detects the broken patch of ground and rebuilds it instead of bouncing you forever — and ground under players jumps the rebuild queue when the server is busy.
  • Editing terrain no longer teleports players back to where they spawned or last stepped through a portal — anyone who has moved stays exactly where they are.
  • Driving feels steadier online: the main speed readout cars use now rounds the same way on your machine and the server, so steering and gear logic built on it stops triggering tiny corrections while you drive.
  • Closed the remaining ways a solo world with multiple areas could suddenly show other areas as empty: changing an object's behavior or toolbar metadata, resetting the world, and the engine's automatic model-size bookkeeping were all still able to trigger it.
  • A join that hangs no longer strands you on a silent loading screen — it retries automatically, the same way failed joins already did.
  • Places no longer fight over objects that share a name. If your arena and your stages both have a "wall-north", every copy now spawns and stays in its own place — nothing silently goes missing when multiple places are loaded at once.
  • Interaction prompts now float above things instead of inside them — "Press E" sits over a character's head, not in their chest.
  • Joining a multiplayer world never puts you in another player's character again. One player's live state used to overwrite the shared player template — now each player gets the template you authored, and solo worlds keep persisting your own character exactly as before.
  • Sound effects no longer fade away and die over a long session: every sound now frees its voice slot, every time — busy moments can't permanently silence your world, and a clip that fails to load can't jam the audio engine anymore.
  • Phones that lose the GPU mid-session or after a refresh now get an honest reload wall instead of a frozen black canvas, and leaving the page returns its GPU memory immediately.
  • Mobile gets its triangle budget back: phones cap at ~1M triangles with a degradation ladder — decoration thinning, earlier model LODs, tighter far-culling — driven by the same governor that already scales resolution.
  • Game UI now styles correctly on iPhone and Safari — HUDs, menus, and buttons no longer collapse into unstyled text in the top-left corner.
  • Worlds with animated sprites (2D characters, billboard NPCs) no longer stutter from constant prediction corrections — sprite animation is smooth even in multiplayer.
  • Phones render dramatically sharper — the resolution cap doubled, so a 3× phone now draws 4× the pixels it did. It's paid for by effects a phone screen couldn't resolve anyway: MSAA, the engine's default bloom glow, two cloud-noise octaves, and steep-slope terrain projection. Anything you or Savi explicitly authored — bloom included — renders exactly as set, on every device. Desktop is unchanged.
  • Phones also skip shadow rendering and heavyweight terrain material effects — the same world, several milliseconds lighter every frame — and games run smoother across the board now that the engine stopped paying for invisible work in every frame.
  • God mode no longer fights you in multiplayer: flying with a blueprint in hand could rubber-band every frame. Flight state now stays identical on both sides, and attached props no longer grind the prediction system into constant corrections.
  • Duplicating an object in god mode no longer leaves the old object's buttons stacked on the Place/Cancel controls — one clean placement surface while the copy is in hand.
  • Fixed a wedge where a game could permanently stop seeing number keys and Space (while movement still worked) after overlay/Tab interactions — the engine now detects the stale state and recovers within seconds.
  • Long sessions with lots of sounds and effects no longer risk the renderer silently dropping objects when an internal 65k budget filled up — the budget recycles itself continuously now.
  • Savi no longer gets spammed with duplicate engine warnings dressed up as your god-mode edits — she hears about a problem once, with a count if it kept happening, and her picture of what you built stays clean.
  • A whole class of multiplayer stutter bugs (constant invisible prediction corrections) now gets caught while engine features are being built, and worlds that hit it report a clear diagnostic instead of silently re-simulating every tick.

Engine v5.0.2

Released June 6, 2026

  • The editor cursor finally feels like your real mouse — pixel-for-pixel, no more laggy ghost trailing your hand in god mode
  • Joining a world that's mid-wake retries automatically instead of sometimes sitting on a silent loading screen

technical notes

  • Overlay fake cursor rebuilt for native feel: the game iframe relays pointer position from pointerrawupdate (input-device rate, dispatched on arrival instead of rAF-aligned — kills the structural ~2-frame postMessage delay), and the parent writes translate3d directly in the event handler on a compositor layer; hover mirroring, edge push, and the cursor store publish read the accumulated position once per frame, never blocking the write
  • join.failed closes the socket with an explicit retryable app code (4430) — the previous bare close reached browsers as code 1000, which the client reconnect guard reads as a clean close and never retries (a silently dead loader when a room was mid-hydration)
  • The spec-hydration retry heals a partial apply: a world holding a spec whose ready flip was interrupted now flips ready on the next retry tick instead of holding every joiner in Loading forever; a retry that declines to arm logs its cause
  • Regression pin: 161 same-GLB objects with per-object scale/yaw instance into exactly 6 batches (the "5.0 not instancing" report's refutation, kept as a guard)

Engine v5.0.1

Released June 6, 2026

  • Scripts that animate with Math.sin, ease with Math.exp, or steer with Math.atan2 now compute the exact same bits on the server and on every player's machine. Before, different browsers' built-in math disagreed by invisible amounts that physics contact could amplify into visible corrections — cars and props you drove through obstacles could stutter as the server "corrected" the client mid-drive. Math results may shift by amounts far below anything perceivable (the same on every machine), in exchange for driving, easing, and orbiting code that no longer fights the network.
  • Way faster world loading on big landscapes — your game no longer builds a kilometer of invisible terrain before it lets you in
  • Fixed a freeze-then-storm that could happen if terrain got stuck building behind the scenes
  • Savi gets honest signals while your world is still loading, so she stops guessing at the wrong cause
  • Custom cameras in 2D worlds work again — the camera you authored is the camera you get
  • Smoother building: changing fog or adding textures mid-session no longer causes frame hitches
  • Editing room layouts updates the physics instantly — no more invisible walls where old walls used to be
  • Removing a world's terrain actually removes it now
  • Room-builder worlds got a deep clean: doors at corners and junctions no longer leave holes, doors in raised rooms actually open onto the floor, pits are really pits, and indoor spaces finally have proper shadows
  • Savi can't silently go quiet mid-build anymore — if a reply fails, she tells you and your message is safe to resend
  • The ground half of realistic skies follows your chosen ground color now (sunset tints it like everything else)
  • Cars and other multi-piece builds no longer look like they're pulling apart while you drive them — wheels, body and spoiler stay welded to the chassis on screen, including when you slam into things on a laggy connection.
  • Walking into a door or portal no longer rubber-bands you right after you arrive — the first steps in a new place are smooth instead of snapping you back once before settling.
  • Long play sessions no longer hiccup every 10 minutes — the brief "Connection lost. Reconnecting..." freeze that interrupted games on a timer is gone. Your game's server now stays up as long as anyone is connected, and still winds down a few minutes after the last player leaves.
  • Multiplayer physics gets calmer: crates and props you stand next to (or on) no longer flicker between asleep and awake under the hood, which removes a whole class of tiny rotation jitters and client/server corrections around settled piles. Pushing, shoving, and knocking stacks over feels exactly the same — things still wake the moment anything actually touches or hits them.
  • Cars, physics props, and characters no longer fight the network over invisible precision: speeds read in 0.05 m/s steps and physics positions in 1 cm steps — far below anything a player can perceive — and in exchange, gameplay state derived from them (steering, gear/RPM, follow distances) stays perfectly in sync between server and client instead of triggering correction churn while driving.
  • Big builds feel smooth while they appear: dropping in lots of models or recoloring many things at once no longer freezes the frame — new content fades in as it's ready instead of stopping the world
  • Changing the sky or environment mid-session no longer hitches the whole scene
  • Giant interior worlds (room builder) load dramatically faster and stay fast
  • A typo in a room layout can't erase your terrain anymore — Savi gets a note about exactly which character was wrong and the rest of the world builds fine
  • Rivers and lakes stop glowing in the dark — water finally goes properly moody at night
  • Movement on a shaky connection feels calmer: brief input hiccups smooth out in one correction instead of repeatedly tugging you back
  • God mode got a polish pass: material edits stick, placed shapes stay put, every part of a combo object gets its tuning chips, menu picks always hit the item you clicked, and opening god mode no longer triggers your microphone
  • Joining a world that's still waking up now waits at the loading screen instead of dropping you into an empty default world

technical notes

  • The script-sandbox Math shim (every script compiled through tome/compiler.ts: behaviors, generators, jobs, camera, terrain/geometry/voxel/spline/fx/IK) now routes every implementation-approximated Math member through a deterministic table (tome/deterministic-math.ts, bridged as globalThis.__tomeDetMath — same pattern as __tomeSeededRng). ECMA-262 leaves sin/cos/tan/asin/acos/atan/atan2/exp/expm1/log/log1p/log2/log10/pow/cbrt/sinh/cosh/tanh/asinh/acosh/atanh/hypot implementation-defined; the server (bun/JSC) and client (Chrome/V8) natives disagree by 1-2 f64 ulps, so a mode-"both" behavior calling them seeded state/motion divergence on every tick it ran (wave-8: the straight-road car is bit-clean parked, noisy driving). f32 output quantization could not close the seam — two implementations near a quantization boundary still split; computing identically does.
  • Kernels: dexp/dlog/dtan/dpow are faithful fdlibm ports (e_exp.c, e_log.c, s_tan.c+k_tan.c, e_pow.c incl. scalbn) added to engine/physics/mantle/math/scalar.ts next to the existing dsin/dcos/datan family, built from exactly-rounded primitives and spec-defined IEEE-754 word access only. The fdlibm e_pow special-case ladder is the ECMA-262 Number::exponentiate table (V8's own Math.pow is this port), verified case-by-case. The long tail (log2/log10/log1p/expm1/cbrt/sinh/cosh/tanh/asinh/acosh/atanh) is derived in tome/deterministic-math.ts from those kernels with fdlibm-structured identities and exact ops — derived, not faithful ports, and labeled as such. hypot is deliberately REWRITTEN as sqrt of the exact-op sum of squares (native hypot is implementation-defined — wave-8 removed it from the engine's own rotation sanitize for the same reason); on f32-quantized sandbox inputs its overflow-scaling regime is unreachable.
  • Precision contract: deterministic ≠ native-exact. Sandbox transcendentals can differ from a given engine's native Math by ~1-2 f64 ulps (kernels ≤1, tan ≤3 at extreme reduction args, derived ≤3, measured over 50k-200k point sweeps) — on BOTH sides equally, so existing game behavior changes microscopically and uniformly. The existing f32 in/out quantization wrappers are kept and composed unchanged (fround(det(fround(x)))): sandbox numbers keep living on the f32 grid, the consumed contract of state codecs and the tome/state compare, and the f32 grid (2^29 × coarser than f64) absorbs almost every det-vs-native ulp delta. Spec-exact members (abs/ceil/floor/fround/imul/clz32/max/min/round/sign/sqrt/trunc) stay native; Math.random stays on the seeded-RNG bridge; Math constants now come from exactly-parsed literals (still f32-quantized).
  • Cross-runtime proof: 1.9M-sample bit-pattern hash identical on bun/JSC and node/V8 (natives differ on the same stream); pinned exact f64 bit patterns at boundary-hostile inputs (denormal in/out, overflow edges, near-π/2, 2^20-edge reduction, negative/huge pow operands) in scalar.test.ts and deterministic-math.test.ts fail on ANY future runtime divergence. Golden FNV hashes extended to the new kernels.
  • Perf: deterministic sin+exp pair ≈ 1.5-1.8× native (35ns vs 20ns per pair over 1e6 calls); pow is the heaviest at ~3.4-4.8× in a pow+tan+log mix (~100ns/triple). Behaviors are not the sim hot path (the engine's own kernels were already deterministic); correctness over micro-perf.
  • Known residual: the ** operator in creator scripts still lowers to the runtime's native pow (the shim shadows the Math object, not operators). No in-repo game script uses ** today; closing it would need an AST rewrite in the compiler.
  • Client chunk-build liveness: stale/lost terrain build jobs are re-armed instead of parking forever (mirror of the server-side collider-gate fix) — ends permanent grounded/ceiling mismatch storms after a wedged build
  • Extended-profile terrain streaming clamps appended far bands at actual visibility (fog saturation × screen-corner factor, camera far plane) — lobby-shaped places drop ~4,225 → ~961 desired chunks on boot; authored lodRanges stream exactly as authored
  • Material compile scene-sweeps coalesce to one deferred sweep per 250ms during chunk-install storms (single installs unchanged)
  • Frame-budget CPU-bound diagnostics now report live terrain build backlog ("terrain build in progress: N/M chunks") instead of misdirecting at object counts
  • run_script execution gates on world hydration (bounded 10s wait) and returns an honest world-not-ready error instead of succeeding against an empty world
  • 2D places no longer replace authored cameras with the builtin follow cam (regression since 2026-05-02) — custom camera scripts, setCamera() overrides, and overlays run again in 2d-top/2d-side places; the builtin only fills absence
  • Fog changes (add/clear/linear<->exp2/color/range) no longer recompile the whole scene — one retained fog node per scene, all fog ops are uniform writes
  • Async texture arrivals (MagicCDN) no longer recompile live materials mid-frame — texture channels bind a placeholder at material build and arrival is a binding swap; adding a texture to an existing object compiles once, not twice
  • 3d-rooms collider signatures are content-derived — live layout edits (move a wall, punch a door, raise a floor) rebuild physics on both engines instead of leaving stale colliders
  • GPT-lane wisp dispatch schema fixed (z.tuple -> fixed-length array) — technical wisps work again (chat deploy)
  • The mismatch digest splits server-input-decay ticks (late frames) into their own decayed-input bucket instead of paging them as drift
  • updatePlace({terrain: null}) now fully turns terrain off ({kind:"off"}, monolithic) instead of silently resurrecting the default starter ground
  • 3d-rooms overhaul: openings stay watertight at any wall topology (corners, junctions, wall-ends); walls and openings measure from the local floor datum (doors in raised rooms are passable); render/physics parity everywhere (boundary walls solid, void pits open, no outside-layout rescue floor); ambiguous door orientation degrades with a warning instead of wiping the place's terrain; trim follows the cut; documented roughness/normal-map fields work; interiors receive and (tier-gated) cast shadows
  • Chat resilience: model output is UTF-16-repaired at the state boundary (one malformed emoji can no longer permanently wedge a room), failed turns say so in chat instead of vanishing, repeated persist failures escalate loudly
  • sky.groundAlbedo now colors the entire below-horizon sky dome, lit by the live sky (dusk tints it); unauthored skies are byte-identical
  • Zoo: new Rooms 3D museum zone (key 8) exercising every 3d-rooms feature
  • Parented assemblies no longer render non-cohesive while driven (ledger #136 — Tucker's race car: parts trail the chassis "most of the time" and pull apart under physics interactions). Root cause: timeline-frame mixing around the locally-predicted assembly root. The predicted root's live pose runs AHEAD of the server (client prediction), while its non-predicted children carry direct-applied SERVER-frame poses; every place that divided or composed the two across frames folded rootVelocity × timelineSkew (≈ speed × RTT, meters at race speed) into the assembly:
    • tome/local-transform-projection derived child Local* as inv(parentLive) ⊗ childServer — laundering the skew into the authored local offsets, which both hierarchy solves then faithfully composed.
    • The renderer's smoothing (synthetic-transform-delta) converted child rows to parent-relative space against the parent's NEWEST ring snapshot even when the row's batch tick was older (resim rewrites, burst delivery) — polluting the rel timeline through every correction.
    • Children with their own dynamic physics bodies were skipped by hierarchy-render-solve (physics owns their pose) and classified client-predicted (body-type branch), so nothing ever expressed them in the assembly's frame at all.
  • Fix — one law, applied at each composition root: every member of a parented assembly is expressed in ONE frame before use.
    • Local* reconstruction for non-predicted children now divides entirely inside the authoritative frame (ServerOplogResource newest state — both operands server truth); predicted entities keep the live division (their frames already agree). New shared reader: tome/systems/authoritative-world-frame.ts.
    • The renderer's world↔rel conversions are tick-coherent: pendings convert against the parent's ring SAMPLED AT THE BATCH TICK (worldPoseAtTickInto/sampleRingAtInto, shared with the displayed-pose sampler) instead of the newest snapshot. Past-newest semantics split by call site: displayed-pose sampling keeps the playout clock's velocity extrapolation (bounded by MAX_EXTRAPOLATION_TICKS); historical conversion sampling clamps at the newest snapshot — a ring with no row at the tick means the entity didn't change that tick, and a resting parent must never be extrapolated into a child's conversion.
    • hierarchy-render-solve rebases non-predicted dynamic-bodied children of a predicted assembly root into the parent's live frame, preserving the SERVER's parent-relative articulation: childLive = parentLive ⊗ (inv(parentServer) ⊗ childServer).
    • Prediction classifier: a dynamic body WELDED into an assembly (tome/parent) is no longer client-predicted — nothing on the client simulates the weld (no physics joint for the hierarchy edge; hierarchy solves skip dynamic bodies), so predicting it only withheld its replicated pose and ground the mismatch comparator against truth the client cannot reproduce.
  • draw/interpolation still does NOT propagate parent→child, by design: a rigid child rides the parent's DISPLAYED pose (window-4 hierarchy-consistent interpolation), so its own missing/default config cannot create an independent world timeline; an authored child config only shapes parent-relative articulation smoothing.
  • New fail-on-parent suite engine/runtime/__tests__/car-weld-displayed-rigidity.test.ts: the full-stack harness (real server netcode + acks + client prediction + render channel + playout clock + renderer smoothing) extended with genuine client-AHEAD prediction of the driven root and a server-side collision jolt the client cannot predict. Asserts < 0.1 m displayed-pose rigidity across correction events for interpolation-less physics children, pure visual children, scripted (server-realm) physics children, and the authored-override child. All four fail on the parent commit (up to 2.9 m of drawn separation); all pass with the fix.
  • One-time rubber-band/snap when starting to move right after entering a place (ledger #133, dump c871a5d5 — tiger: "right in the beginning when I start moving it snaps/rubber bands me, then I move fine"; mismatch record: 7 mismatched ticks at entry, world-feet-position x/z up to 2.62 m + state.velocity rows, 0% drift steady-state after). Root cause: the place-transition projection reset discarded the traveler's own in-flight input tail. Walking through a portal, the client keeps sending tick-stamped movement frames for the travel round-trip (the human can't see the new place yet); the server correctly applies them in the NEW place (walk-through-the-portal continuity). The client's reset handling adopted the snapshot wholesale and WIPED the stored input history (getPlaceTransitionTick in runtime-client), so it could neither replay the tail nor re-predict the ticks between the snapshot and its own lead — a guaranteed divergence of the tail's displacement, hidden by the 30-tick place-transition compare-suppression window, then cashed out as one visible correction at the first compare. A second seam booked a phantom drift mispredict at the travel tick itself: pending compares referencing the abandoned pre-reset baseline ran one phase before spec-sync could stamp the settle window's resource.
  • Fix — the reset is reconciled like a correction, not a wipe:
    • ClientRuntimeHandle.noteProjectionReset(serverTick) (called from runtime-worker's handleProjectionReset): drops pending compares against the abandoned baseline (same rule as adoptLatestAuthoritativeBaseline) and schedules a reset-anchored rollback+replay of the surviving input frames after serverTick at the next simulation slot — presentation-invisible (runs before the tick's simulation phase commits). Joins and idle travels replay nothing (no frames after the baseline) and keep today's behavior byte-for-byte.
    • The place-transition input-history wipe is removed. Replays can never reach pre-reset ticks — both oplogs rebase to the reset's baseline, so no mismatch anchors before it.
    • The reset replay rolls back with a WHOLESALE scope (wholesale: true on runResimulation): a selective scope derived from the old baseline's deltas would leave the reset's own writes (place membership, teleported transforms) stale in the client oplog base and book phantom compares the next time the entity changes.
    • Reset replays are baseline maintenance, not mispredictions: they no longer book a mismatch tick into resimulation stats.
  • isRemotePredictionEntity reads TomeBehaviorRef through tryGetComponent like every other component read in the classifier (the one bare world.get threw on minimal test registries).
  • New fail-on-parent suite tome/__tests__/place-travel-input-tail.test.ts: real server netcode + input server (router/acks) + tome input applier + behavior update + ObjectAPI.enterPlace, against the real client runtime (prediction capture, mismatch detection, settle window, resimulation) wired exactly like runtime-worker, over a transport with real latency. Pins: walking in the origin place predicts cleanly; the server player lands at the destination spawn and KEEPS WALKING on the in-flight tail; and the whole travel → stand → first-movement sequence books ZERO mismatched compares with both sides converged on the tail's end position. On the parent the same run books a drift mismatch at the travel tick (server at home spawn + tail, client still on the old-place prediction).
  • Continuous-play sessions no longer freeze 3-5s every ~10 minutes (ledger #147 — tiger's team report: [kernel-startup] "Connection lost. Reconnecting..." on a clean 10-minute cadence). Root cause lives in cf-edge's container lifecycle, not the kernel: the @cloudflare/containers lib renews its sleepAfter activity clock only when a request is PROXIED through the DO (containerFetch → renewActivityTimeout, once per request). A gameplay WebSocket renews exactly once — at the upgrade; after the 101 the frames flow through the runtime's spliced connection without invoking DO code. A room whose only traffic is its gameplay sockets therefore looks idle to the DO, and the lib's alarm loop SIGTERMs the container every sleepAfter window (10m) mid-session. Every player's socket dies with it; the client's reconnect (1s first retry, 1.5x backoff) lands on a container cold start — the 3-5s freeze.
  • Kernel half of the fix: the network worker (port 4001, the gameplay-socket owner) now answers GET /connections with { connections: <open gameplay-socket count> } (gameplay-connections-probe.ts). It lives on 4001 because that worker OWNS the socket table — the main container server's /health (port 4000) cannot see it — and 4001 is unreachable from the public internet (cf-edge forwards only gameplay-socket WS upgrades there), so the probe needs no auth.
  • cf-edge half (ships with cf-edge deploy, same change train): GameContainer.onActivityExpired now probes /connections before honoring an expiry. Open sockets → the stop is declined and the lib re-arms the clock (next probe one sleepAfter window out — one local HTTP request per 10 minutes, never on the frame path). Anything short of positive liveness evidence falls through to the default reap so idle containers never leak: zero connections, a 404 (pre-patch 5.0.0 network workers don't serve the path), no 4001 listener at all (pre-5.0 kernels), or a probe timeout (wedged kernel) all keep today's behavior. Until a kernel patch carrying this endpoint is pinned, rooms keep the old 10-minute reap — the fix completes when both halves are live.
  • Deliberate consequence to know about: a room with an abandoned-but-open tab (live TCP, nobody playing) now keeps its container running indefinitely instead of being reaped at 10 minutes. An open gameplay socket is the engine's definition of "someone is in the room"; if longer-lived containers turn out to matter for billing, the lever is counting only recently-active sockets, not reverting the liveness probe.
  • Fail-on-parent suite apps/cf-edge/test/game-container.test.ts (cf-edge's first vitest setup) drives the containers lib's REAL alarm loop under fake timers — simulated 10-minute windows, never wall-clock. On the parent commit the live-socket expiry SIGTERMs (the bug); with the fix it declines, re-arms, and still reaps once the room empties / the probe fails. Kernel-side probe matching is pinned in gameplay-connections-probe.test.ts, including never shadowing a WS upgrade.
  • Mantle wake-on-contact now requires an actual disturbance (manifoldDisturbsSleeper in solver/step.ts): a sleeping dynamic body wakes only when a manifold point is touching (pre-solve separation ≤ 0, where bias pushout acts) or the counterpart is closing faster than SLEEP_LINEAR_VELOCITY (where the solver applies real impulse). Mere presence in the speculative band (0 < sep ≤ 2 cm) no longer wakes — mirroring the event book's no-hysteresis overlap rule (speculative points never counted as overlap either).
  • Why: resting configurations live inside the speculative band permanently — the character controller's padding gap is exactly SPECULATIVE_DISTANCE (2 cm), and settled stack neighbors sit within slop. Proximity-waking made every sleeper adjacent to an awake body a period-16 asleep/awake oscillator whose phase flips on sub-epsilon pose differences. On predicting clients this was the dominant remaining mispredict class: a crate asleep on the server (PhysicsBodyState.status=1) stayed awake on the client for hundreds of counted ticks, micro-rotating ~0.001/tick, re-slept by every rollback restore and re-woken by the next step's wake pass. Probe-measured wake census in the crate-stack-shove grind: 12 of 13 contact wakes fired at separations 0.0085–0.0198 (speculative band), every one on the body's first asleep tick (sleepTicks=15).
  • The gate is a pure function of pose + velocity lanes (replicated state) — client and server gate identically; no new cross-tick state, nothing new on the wire.
  • Grind evidence: crate-stack-shove (mantle) counted mismatch ticks 30/155 (two baseline runs, counter still climbing at session end) → 25/42 post-fix with the counter dead flat for the final ~23 samples of identical shove choreography; post-fix wake census shows zero speculative wakes (6 penetrating + 16 fast-closing, all legitimate). Stacks still topple and scatter identically; drive-vehicle mount/drive/dismount unaffected (its residual mismatches are the known steering-echo value class, no sleep-status rows).
  • Physics-derived ObjectAPI reads quantize to their correction epsilon's grid at the read boundary (tome/api/physics-read.ts). Server and client bodies legitimately drift below the mismatch-detector epsilons (corrections only fire above them), and raw reads leaked that sub-correction drift into behavior-script state: getVehicleSpeed() → steering target → patchState({ currentSteering }) flagged the exact tome/state compare on every steer flip (105t mantle / 52t rapier in the car-thing acceptance ledger — the #2 volume class, engine-agnostic). The tome/state compare itself stays bit-exact; no compare-side fuzz.
  • Single source of truth for the grids: the mismatch-detector correction epsilons moved to engine/prediction/epsilons.ts and are imported by the detector, the transform component correction thresholds, and the read boundary. Velocity reads (getVelocity, getObjectVelocity, getVehicleSpeed) snap to DEFAULT_VELOCITY_EPSILON (0.05 m/s); physics-driven pose reads (feetPosition/rotation/yaw properties, query/getPlayers/nearest/getControlTarget result positions, query self-origins) snap to the pose correction grids (1 cm / derived quat-component grid with re-normalization); getWheelState.suspensionLength/.steering snap to the vehicle-config epsilon (1e-3); api.raycast hits against physics-driven bodies snap point/normal/distance; NPC moveTo/flee distances and canSee results snap to the position grid.
  • The gate is isPhysicsPoseDriven: dynamic bodies (rapier or mantle), character controllers, and children attached under either. Authored values never quantize — spec poses, kinematic script-driven bodies, local transforms, terrain/voxel/spline queries keep full precision, so authored set→get round-trips stay bit-exact.
  • The honest residual: a fixed grid cannot eliminate boundary straddles — two values within epsilon can sit one grid cell apart, with probability ≈ drift/grid per read (~1e-5 for the dominant 1-f32-ULP drift class at 1×ε grids). A straddle costs one tome/state correction blip, not an echo family. Pinned by tome/api/__tests__/physics-read.test.ts, including an engineered worst-case midpoint test documenting the residual shape.
  • getWheelState.rotation (wheel visual spin) stays raw: it is a render-only accumulator that drifts by radians across sides by design (compare-exempt in the detector); no grid can collapse it. Deriving replicated script state from it will echo — it is presentation-only.
  • Retained environment slot: equirect/sky-kind environment changes are texture value swaps on one engine-owned PMREM node per scene — environment flips and IBL texture arrivals no longer recompile the whole scene
  • Uniform-class material values destructuralized: primitive tint, water material overrides, and model-batch color/metalness/roughness/emissive/opacity overrides ride retained uniforms and per-instance attributes instead of the structural batch signature — live recolors/tints no longer rebuild shaders or fragment instanced batches; sprite/text texture swaps stop bumping material.version
  • Compile-queue hardening: build-window compile starts are bounded per frame and ride one shared async compile; asset-arrival and structural-sky scene sweeps coalesce through the existing 250ms window; sweeps never re-hide objects whose materials already compiled; new primitive lanes/pools/sprite batches compile hidden-until-warm instead of freezing the frame; scene capture and object preview stop forcing full recompiles; the CPU-bound frame report gains an honest "N materials compiled synchronously inside render frames" counter
  • Visual instantiation is collect-budgeted: a single-frame content flood (mass spawn, place entry) is applied a few entities per frame under an adaptive ~6ms budget instead of freezing one frame for the whole flood, and the model handler packs batches once per tick instead of quadratically per attach; the frame report names the backlog while it drains
  • 3d-rooms geometry scales with visible structure, not tile count: merged wall solids with hidden-face culling (~111× triangle reduction on a 64×64 layout) and colliders built from the merged boxes (482ms → 14ms create on the same layout); auto room lights cap at 256 keeping the largest rooms
  • 3d-rooms layout parsing degrades instead of throwing: unknown glyphs read as empty space, blank/oversized layouts become empty/truncated terrain, malformed legend entries are tolerated — each with one deduped warning to Savi naming exactly what was wrong; an author typo can no longer wipe a place's terrain
  • Decayed-input convergence: the input ack now carries the exact post-sanitize decayed axes the server applied on late-input ticks; the client rewrites its stored input history to match and resimulates — a network hiccup converges in one correction instead of booking a mismatch every decayed tick
  • Pre-hydration honesty: a configured room whose spec fetch fails no longer boots the default playground as if it were the game — the world stays unhydrated (joins hold at Loading and retry) and the room re-fetches the real spec on capped backoff; run_script against such a room reports world-not-ready instead of an empty world
  • Multiplayer client-realm parented spawns compose their World transforms at attach — child objects spawned into a hierarchy mid-session no longer render torn from their parent until the first movement
  • God mode: material and property edits round-trip losslessly (flat map/tint edits no longer dissolve on the next tick); shapes placed from god mode default to static physics; mixed objects (primitive + audio/fx/light/particles) derive tuning chips for every family they carry; menu picks resolve by item identity so a shifting list can never apply to the wrong object; entering god mode no longer triggers the voice input (chorded Tab is never a voice press)
  • Terrain water participates in lighting: the water emissive node honors emissiveIntensity (it was bypassed), and the terrain-liquid default of emissive=own-color at intensity 0 now contributes exactly zero — rivers and lakes stop self-glowing at night; authored glow (lava, magic) scales correctly
  • /play/[updateSlug] rooms honor the slug pin instead of serving the latest publish
  • Rapier character-controller contact debug labels read the obstacle normal correctly (floor contacts no longer logged as "ceiling")

For Real: engine v5.0.0

Released June 4, 2026

5.0 is the biggest engine release Spawn has ever shipped. The theme, if it has one: your world got real. The sky scatters actual sunlight and hands over to a moon at night, the ground has depth you can see and edit instantly, enemies can genuinely find you, and multiplayer runs on a new engine — with matchmaking your game controls. Nothing here needs migration: your game stays on the engine it was built with until you take the update — ask Savi, or hit the "New features" button in the project tab — and when you do, it keeps working exactly as written: much of this turns on the moment you update, and the rest is one flag or one sentence to Savi away.

Storms, explosions, magic — a million particles. Effects used to hit a hard ceiling around twenty thousand particles; now the graphics card runs them itself.

  • Ask Savi for a snowstorm you can stand inside and she builds it — over a million particles without slowing down. The nebula in the clip below is about half a million motes you can walk through.
  • Effects carry real light: a campfire genuinely warms the cave around it, an explosion flashes the walls.
  • When Savi invents a brand-new effect mid-conversation, it appears the same instant — no shader compilation, no stutter.
  • Ask her for smoke that darkens the room instead of glowing and she has the tool: a new subtract blend mode for particles and sprites — smoke shadows, ink clouds, energy drains.
  • Glow, smoke, and fog finally play fair: bright glows always draw on top of smoke, overlapping smoke puffs sort properly instead of flickering, and distant fire keeps its color in fog instead of turning gray. Effects using the old screen blend now render as additive glow, which behaves correctly on bright scenes.

Every light you place actually lights the world.

  • Streetlights, neon signs, lanterns, torches — hundreds at once (our test scene runs roughly 950) instead of only the few nearest the camera, and the lighting no longer shifts when you turn around.
  • The sky now gives your world soft ambient light when you haven't set your own, so outdoor scenes stop looking dim — blue-tinted by day, warm at sunset, dark at night.
  • Sun shadows are steadier (no more flickering while nothing moves) and reach farther into the distance, and the engine picks lighting quality to match each player's device, so big lighting setups stay smooth on phones.
  • Point a spotlight at something by saying where: light: { kind: "spot", aim: { x, y, z } } — no more yaw/pitch math to rake a spot down onto a pedestal. aim: "down" for ceiling lights, aim: "forward" for headlights and flashlights.
  • Anything you set yourself — ambient, hemisphere colors, sun values, or a deliberately pitch-black scene — stays exactly the way you made it.

Ground and walls with real depth. One flag — pbr: true — turns any texture into a real material.

  • Terrain blends like actual ground: grass grows up between cobblestones instead of cross-fading, the surface has real relief up close, and materials respond to light correctly (normals, roughness).
  • Tell Savi a wall should be brick and it's actually brick: she sets one flag — pbr: true — and on capable desktops it gets depth, self-shadowing between bricks, notched brick silhouettes along its edges, and true displaced geometry up close — with the detail handing off smoothly as you walk away. No extra setup.
  • Tiled floors and long paths stop looking like a copy-pasted grid: textures repeated across big surfaces now vary subtly tile to tile, the way real stone and brick do — automatically, with small textures and props keeping their exact authored look.
  • Resize a texture pattern the natural way: textureScale: 2 makes cobbles, bricks, or planks twice as large — it composes with the automatic real-world tiling instead of fighting it.
  • Everything is tiered for the player's device: phones and weaker laptops keep the simpler look at full speed.

Dig and build with no lag — and it's saved for good.

  • Terrain editing is instant now: break a block and it's gone, place one and you're already standing on it. Big builds land at once instead of trickling in block by block.
  • Savi got bulk tools too — big builds land as a few commands instead of hundreds: fill a region, carve a moat, stamp a whole watchtower in one go. A wall that used to take 512 separate commands is now 4, so building together keeps pace with the conversation.
  • Every edit is saved automatically, in every 5.0 world, with no setting to remember — come back tomorrow and your tunnel is still your tunnel.
  • Block worlds finally sit in their lighting: terrain casts shadows now, so a tower throws a long shadow at sunset and a canyon floor falls into shade.

The sky is real now. Every world with a procedural sky gets a physically real one, automatically, the moment it's on 5.0.

  • Sunrises, sunsets, twilight, and a sun you can look at. Stay out past dusk and the stars fade in, the milky way arcs overhead, and a moon with real phases rises — and actually lights the world, so a full-moon night is silvery-bright and a new-moon night is properly dark.
  • One number scrubs the whole day: set timeOfDay and sun position and color, ambient light, reflections, fog, stars, and the moon all move together with no scripts. 0.8 is a sunset; one cycle line is a day/night loop that runs forever.
  • Clouds live in the sky itself now: they drift with the wind, catch sunset colors, glow silver under the moon, and dim the world when it's overcast — the way an overcast day actually feels.
  • Want somewhere else entirely? Sun size, horizon tint, milky way brightness, moon size and phase are all dials now — ask Savi for "a Mars sky" or "permanent golden hour" and the atmosphere itself changes, lighting your whole world with it.

Multiplayer, rebuilt — and your game runs the door.

  • Multiplayer got a whole new engine under the hood: smoother movement, and hits on moving players land where you actually aimed — the server rewinds up to 300ms to where the target was on your screen, so if your crosshair was on them, the hit counts.
  • Rooms hold about 100 players, and when one fills, a fresh server boots automatically and the next player rolls in — your game scales sideways with zero setup. Under the hood the server writes one update per neighborhood instead of one per player; on our benchmark that cut the per-tick sync cost at 1,000 players from about 119ms to about 6ms — headroom to spare.
  • Click Play and you're in: joining a world that's already awake lands in about a second, and waking a sleeping one now overlaps with your page load instead of stacking after it.
  • Ask Savi for matchmaking and she writes it into your game: keep returning players on their old server, fill the fullest open room before starting a new one, shard by region — a few lines of routing code your game owns and decides which room each player joins. No code at all gets you sensible defaults.
  • Rooms have a real player cap: a full room turns players away at the door and routing automatically rolls them into the next room, creating a brand-new one when everything's full. Players just land somewhere playable.
  • Ask Savi for a live server browser and she builds it from real data: server lists, matchmaking queues, and per-server saves can now be listed, queried, and locked from in-game scripts.
  • Preview: Mantle, our own deterministic physics engine, built from the first line for multiplayer prediction — opt-in per world via physicsEngine: "mantle"; rapier stays the default for every game.

Enemies have brains. Ask Savi for "an enemy that patrols and chases me when it spots me" — one sentence, and the engine handles everything that used to go wrong.

  • Smart movement — enemies walk around obstacles, through doorways, and around each other instead of bumping into walls or piling up. No more zombies stuck on fountains.
  • Smooth movement — NPCs walk, break into a sprint, and turn at a natural rate instead of snapping or moonwalking.
  • They see and hear — give an NPC vision and it spots you (or loses you when you hide); throw a noise and guards come to investigate. Hide and seek is a real game now.
  • Fair fights — enemy groups can take turns attacking instead of mobbing you all at once, and archers keep their distance while staying aimed at you.
  • Tune it yourself — select an NPC in god mode and drag its vision ring, pick its speed and hearing, right in the world.

Savi sees your game now — and she's a shader artist.

  • Savi can watch your game while you play — through your camera, or an invisible one she flies to check the back of the castle without moving your view. What she reports is her view of the world, and she catches her own mistakes by looking, the way you would.
  • Ask for a force field and she writes the actual shader: glowing energy, glass, lava, holograms, dissolves — on anything in your world. Say "slower pulse" or "more green" and it updates live.
  • Ask her to preview something and she'll show you a picture before it lands in your world — even a little filmstrip of it animating, or a sweep of variations.
  • Whole-screen looks: film grain, retro pixelation, scanlines, color grades, underwater moods. Savi writes the look as a tiny script, tweaks it live, and can fade it in and out during gameplay.
  • Every new game now opens with its look already written out as an editable script — the world looks exactly as before, but the dials for color, glow, grain, and vignette are sitting right there. "Warm it up a little" is a one-number edit.
  • If a fancy shader ever makes a game run slow, the engine steps in to keep it smooth and tells Savi exactly what to fix — players never get stuck with a laggy world.
  • For big asks she fans out a whole team of background builders — splitting the work, gathering it back, sending reviewers over anything that isn't right. You watch the flames in the studio footer while it happens, and if the server blinks mid-build, the team picks up where it left off.

Characters and crowds.

  • Characters plant their feet on slopes and uneven ground instead of floating or clipping — and Savi can make them reach, grab, and look: "hand the player the sword" works with no animation files.
  • Ask Savi for a marching army or a festival crowd and the engine animates everyone sharing a model in one pass — the clip below runs 360 characters at once, each still walking at its own pace.

Making a mod is exactly as hard as making a folder.

  • Put your scripts in a folder with a small mod.json, and that folder is the mod — publishing ships every file in it, including teaching docs for Savi and slash commands for players, so there's no way to forget a piece and ship something broken.
  • Every game has a real mod catalog now: browse, search, like, comment, and install in one click — with public, unlisted, and private visibility, so you can soft-launch to friends or keep a mod to yourself.
  • Savi knows the catalog: ask her for a shop system and she checks whether another creator already built a great one before building from scratch.
  • Installed mods are starting points, not sealed boxes: ask Savi to change how one works and she edits it in place — your edits survive the author's updates, and uninstalling removes exactly what the mod brought.

Building in god mode. Editing by hand got quicker and more physical.

  • Spotlights place in two clicks: one for where the light sits, one for what it shines on — the beam follows your cursor between them.
  • Right-click anything for its dials: recolor a lantern from a palette, drag its glow radius, set a sound's volume and range, tune an effect's rate and size — all in place, no typing.
  • Click a house, get the house: selecting grabs the whole assembly instead of one plank, and clicking again drills down to the tower, the door, the doorknob.
  • Every brush erases: hold Alt and brush the extra daisies back out — the ring tints warm so you always know which mode you're in.
  • Paint with anything: turn any object — a mushroom, a crystal, a gravestone — into a scatter brush and sweep copies across the hillside.
  • Building makes sound now: placing, painting, erasing, and undoing each answer back with their own little sound.

technical notes

  • New games (default-game-spec.ts) now ship scripts/look-default.js wired on via atmosphere.look on the default place. The script is a pass-through (look(ctx) → ctx.scene) that surfaces the engine's bloom defaults as live bloomStrength / bloomRadius / bloomThreshold params and carries the grade/vignette/grain dials as commented one-liners, so the rendered output is the engine baseline (bloom 0.15/0.6/1, neutral grade, no vignette) until a creator edits it.

  • The look pass's intermediate "finished frame" target is now HalfFloatType (was UnsignedByte), matching the scene pass's precision so a passthrough look no longer 8-bit-requantizes the tone-mapped frame before the sRGB encode — dark regions and bloom falloff stay smooth. A look-active frame is now indistinguishable from no-look apart from the look's own grade.

  • A base look's bloomStrength / bloomRadius / bloomThreshold params now seed the resolved bloom in the look resolver (clamped to the engine's bloom ranges), so they drive the engine bloom uniforms live with no recompile. Absent params, the resolved bloom stays exactly NEUTRAL (0.15/0.6/1); games that set no look are unaffected.

  • Displaced (subdivided) primitive buckets are re-enabled (PRIMITIVE_SURFACE_DISPLACEMENT_ENABLED = true). The per-column banding that forced the switch off is fixed at the root: the vertex-stage height mip now derives from the face's real UV span (baked per vertex from the geometry's own uv attribute into the displacement-direction .w) instead of assuming unit face UVs, so kind "box"/"wall" faces sample the correct texel density.

  • Displaced relief eases to exactly zero by the coarse bucket's enter distance (18→25 m, measured per instance with the same camera-to-translation metric the bucket pass uses), so instances parked in the 25–29 m hysteresis band render flat and the migration back to the POM lane never pops.

  • The per-instance relief amplitude is defined once in meters: the displaced lanes displace by it directly, and the POM march converts it to tile units with the same native/workspace tiles-per-meter blend its march UV uses — POM depth and displaced geometry stay one number, which is what the displaced→POM handoff parity rests on.

  • Gate: scripts/verify-primitive-pixels cases C6 (column-banding) and C7 (displaced-vs-POM handoff parity at 24/26/29 m) pass on a real WebGPU device; the switch may only be flipped in a change where both pass.

  • Known limit carried honestly: the Nyquist-correct vertex mip is a low-pass — fine texel-scale relief mostly disappears into the 8/16-segment grids on large walls, and the visible displaced relief is the broad height structure. Raising segment counts or amplitude is a named product follow-up, not a reason to sample the wrong mip.

  • Voxel terrain rebuilt end-to-end: grid substrate, binary mesher, same-tick edit fast-lane, region/structure edit API, durable per-region persistence, binary transport, GPU-resident terrain (#6622)

  • Networking stack replaced: new netcode transport, server-side lag compensation (history rewind for player-attributed hits), AOI bucketing + shared encode (#6674)

  • ECS rewritten on sparse-set columns: one write path, one change history, O(changes) drains (#6630); contract in ecs/contract.md

  • Humanoid IK: analytic two-bone solver, automatic foot grounding, updateIK ObjectAPI surface (#6667)

  • FX runtime: field-based effects substrate (.fx.js) + FxVM GPU compute interpreter — 1M+ particles with zero per-effect shader compilation (#6594, #6628)

  • Instanced GPU-skinned crowds: 8+ identical characters auto-batch; complex mixers demote to clone path (#6631)

  • Spawning-bubble lifecycle derived from authoritative state; cosmetic mispredicts no longer trigger resims (#6673)

  • Camera stairstepping root-cause fix: nominal-keyed smoothing alpha (#6650)

  • Cold-start TTI backbone: <1s warm path, overlapped container boot (#6670)

  • Mods: install/edit/private mods + Savi catalog skill (#6645, #6656)

  • Savi: view_live_scene (player viewport / free camera / framed object) (#6657); every authorable surface contained + reported on failure (#6672); run_weave deterministic orchestration (#6668)

  • Terrain client-build: event-driven chunk dirtiness, idle re-hash 2.5–3.4ms → ~0.1ms (#6637)

  • LightSpec (spot + directional) gains aim?: "down" | "forward" | PositionSpec. "down" = rotation·(0,−1,0) (identity points straight down, tilting tilts the beam), "forward" = rotation·(0,0,−1) for BOTH kinds (previously directional rotated +Z while spot rotated −Z), PositionSpec = beam points at the resolved world position (resolved once at apply time, terrain-y and tile forms supported).

  • aim omitted preserves the legacy heuristic exactly: straight down until the entity has any rotation, then per-kind local axis (spot −Z, directional +Z). Documented at LightAim in components/draw.ts.

  • God-mode light editor visuals (beam cone, sun arrow) follow the explicit aim, including PositionSpec aims localized through the anchor's rotation.

  • builtin/primitives spotLight accepts aim in opts.

  • RotationSpec docs now state loudly that lookAt defaults to include: ["yaw"] (swivels, never tilts) and point light-aiming use cases at aim instead.

  • Lighting v2: clustered forward+ lighting (ClusteredLighting / ClusteredLightsNode) — froxel grid + per-frame light data texture replaces the nearest-N point/spot selection (LIGHT_LIMITS); ambient/hemisphere/directional stay uniform-batched; shadow-casting/IES/node lights stay on three's per-light path. Renderer init switch lightingMode ("clustered" | "dynamic" | "default"), per-browser override localStorage["spawn.lightingMode.v1"].

  • Lighting quality tiers (low / medium / high / ultra): one LightingQuality config owns the froxel grid + light capacity, sun cascade count/size/max distance, and local shadow map size; the tier is guessed from the device at renderer init, per-browser override localStorage["spawn.lightingTier.v1"].

  • Sun shadows: cascaded shadow maps (SunCascadeShadow wrapping CSMShadowNode for manual matrixWorld updates) replace the single 4096² follow-camera fit on perspective cameras; orthographic (2D) places keep the single-map fit.

  • Sky-driven ambient: places that author neither ambient nor hemisphere get ambient derived from the sky instead of the flat defaults; explicit values and all-lights-off darkness setups are honored unchanged.

  • The legacy lighting path ("dynamic", 8/8/8/4 uniform-array batching) is unchanged and selectable per browser via spawn.lightingMode.v1.

  • Unified the sprite/particle blend vocabulary in engine/materials/sprite-blend-mode.ts; particle batches and the sprite paths now share one resolver, blend-state table, and color-output shaping.

  • Added "subtract" (ReverseSubtractEquation, SrcAlpha/One color, destination alpha untouched) to SpriteBlendMode and the particle BlendMode.

  • "screen" removed from the forward vocabulary; the literal stays accepted in the type unions and resolves to "add" at material-creation time (the OneMinusDstColor screen term darkens over >1 HDR destinations).

  • "alpha" now composites premultiplied internally: the node renderer premultiplies the material output after fog and blends One/OneMinusSrcAlpha. Visually equivalent for well-formed straight-alpha content, correct at filtered edges, and fog no longer needs the straight-alpha factor trick.

  • Fog only applies to "alpha" content. Additive/subtractive light no longer fades toward the fog color (which injected/carved fog-colored light), and multiply content no longer fog-tints the scene instead of fading to its white no-op.

  • Particle batch render order is blend-aware: alpha/multiply batches draw at order 100, add/subtract at 101, so glow no longer disappears behind smoke spawned later by accident.

  • Each particle batch now writes its packed-instance centroid into its matrixWorld/bounding sphere every frame, so three's transparent sort sees an honest per-batch depth instead of every batch sharing the identity origin.

  • Alpha and multiply particle batches get a per-frame CPU back-to-front sort of their instances against the camera; add/subtract batches skip it (order-independent).

  • Removed the dead material.toneMapped = false on particle batch materials (the WebGPU node renderer never reads it; tone mapping/grading happen in the post pipeline).

  • Repeat-textured batched primitives get default anti-tiling: a per-tile stochastic offset jitter (triangular-grid 3-tap blend, offset-only — no rotation/mirror, so brick courses can't ghost) plus low-frequency macro luminance variation (±6%), both ramping in with the effective repeat (smoothstep 2→4 on the dominant axis) and seeded per instance so identical neighbors decorrelate.

  • Channel coherence: one jitter resolve per fragment is shared by the albedo and normal samples (3-tap) and the POM march base UV (dominant-cell offset), so relief and color always agree on the tile variant. All taps use explicit gradients of the un-jittered UV — mip selection stays continuous across cell borders.

  • Identity contract: at strength 0 the whole feature collapses to exactly the plain sample (weights, offsets, and the macro term all converge by construction) — pinned by pixel case C9 (strength-0 render vs a compile-level off-baseline, bit-identical) alongside a detrended autocorrelation assert that the tile-period repetition actually breaks (0.82 → −0.05 on the staged brick wall) and a variance bound so the jitter can't shred textures.

  • Named follow-ups (recorded, not silently dropped): the standalone-mesh anti-tiled sampler and an antiTiling: false creator override (today a material carrying that override is simply batch-ineligible, which routes it to the unjittered standalone path — correct escape-hatch semantics until the standalone sampler lands); whether strongly embossed Patina normal sets want the jitter on their NRO maps is a taste question for the unify-configs pass.

  • Scripted TSL materials: material: { kind: "scripted", script, params } — material(ctx) builds a node material with require("builtin/tsl") / require("builtin/three"); source ships to the renderer worker via the MaterialScripts library component with a hash-keyed compile cache; ctx.param() values are live uniforms (no recompile on tweak); failures fall back to Std/PBR with scripted-material-* diagnostics; scripted materials are batch-ineligible. New custom-materials skill.

  • preview_object tool: isolated render of any object or material inside the live session, returned inline with a perf line (vs Std/PBR baseline). Supports whole hierarchies, frames filmstrips, params / paramsByFrame sweeps, studio / checkerboard / OKLCH-color backgrounds (auto-checkerboard for transparent materials), and a burned-in caption. Renders at the frame loop's safe point; failures return { ok: false } and never disturb the session.

  • The Look system: atmosphere.look = { script, params } (vibe pattern) — look(ctx) composes require("builtin/postfx") passes (grade, bloom, vignette, grain, film, chromaticAberration, dotScreen, scanlines, dof, pixelate, tint) and raw TSL; rendered as an isolated pass over the finished frame so a failing look can never break the main render (falls back to the un-graded frame + diagnostic); pushLook / clearLook runtime layers with fades; juice effect() / slowMo() / vignette() are now sugar over layers and finally render; patchAtmosphere({ look: null }) resets to baseline; the never-wired look-stack / DrawLook / renderlist plumbing was deleted. New looks skill. Known gap: the lut pass awaits Data3DTexture support in the asset service.

  • Frame-budget guard (default on): sustained low frame rate warns Savi via getLogs + DM naming the active scripted materials/look and pointing at preview_object; sustained worse parks the Savi-authored GPU work (materials → Std/PBR fallback, look → neutral baseline) and editing the script re-enables it. If the slowness isn't Savi-authored work, it only warns.

  • Renderer error containment: shader build-time failures and errors three swallows internally (THREE.TSL: channel, e.g. runaway node recursion) are captured, attributed to the offending material/look, parked, and reported (scripted-material-build-failed, scripted-look-build-failed, renderer-tsl-error, renderer-frame-loop-error); an exception can no longer silently stop the frame loop.

  • Sky v2: Hillaire-style LUT atmosphere (transmittance / multi-scatter / sky-view compute LUTs, rgba16f storage textures) drives the rayleigh/realistic skies under the new renderer switch skyMode ("physical" | "legacy", per-browser override localStorage["spawn.skyMode.v1"]). One persistent uniform-driven background node — day/night cycles and patchAtmosphere are uniform writes, never node rebuilds.

  • Night sky: baked milky-way equirect + analytic flux-conserving stars + procedural moon with phase shading; celestial layer fades in through twilight and is skipped entirely while the sun is up.

  • Cloud layer: wind-driven FBM coverage field lit as a participating medium (Beer–Lambert, powder, dual-lobe HG), rendered in the sky and in the IBL capture; skipped when disabled.

  • Sky-driven IBL: small cube capture of the sky (sun disc off) → in-place PMREM scene.environmentNode, throttled by sun-angle delta per tier. Gating matches lighting v2: authored ambient/hemisphere or the darkness convention force it to 0, and it is suppressed while the zero-authored-light fallback lights are engaged.

  • atmosphere-sync: timeOfDay/cycle drive the sun orbit, derived sun color/intensity, overcast dimming, moon handover, and sky-model fog color; patching timeOfDay during a running cycle scrubs the cycle.

  • Sky quality knobs (LUT sizes, capture size/throttle, night bake size) ride the existing LightingQualityConfig tiers.

  • Post chain: static analytic Bayer ordered dither applied after tone mapping (kills 8-bit banding in dark skies; worker-safe, no texture loads).

  • Magic CDN packed Patina variants: ?transform=albedo-height (basecolor.rgb + height.a, sRGB KTX2) and ?transform=nro (normal.xy + roughness.b, linear KTX2), composed on demand from a bundle's stored siblings and cached like every other derived asset.

  • Terrain height-aware PBR blending: heightmap terrain materials with pbr: true resolve packed albedo+height and NRO arrays in the layer atlas; splat transitions are sharpened by the materials' packed heights (depth-windowed soft-max, distance-faded), and the same weights drive per-layer detail normals, roughness, and metalness through one struct-shared TSL resolve. Non-PBR libraries compile the exact pre-existing shader.

  • Terrain relief: LOD 0 vertices add the blended material height (fading out by 40 m) on top of the heightfield displacement, and a near-field parallax march on the dominant layer adds texel-scale depth within ~18 m; shadows follow via positionNode; sim-side colliders are untouched. Both ease out on steep slopes, where textured layers instead blend toward a slope-facing side projection so texels stop stretching down cliff faces.

  • Primitive surface detail: height-mapped primitive materials get a per-lane gated POM march (8 steps default tier / 16 + self-shadow on high), a box-local silhouette cutout on the high tier (edges erode to the height surface), and near, large boxes on the high tier move to subdivided displacement lanes (real geometry + shadows). Per-instance height layer/scale ride the existing instance storage; lanes without the flag and the mobile tier compile none of it.

  • Quality tiers: high = desktop browsers on discrete or Apple-silicon GPUs; default = other desktops (8-step POM only); off = mobile/tablet.

  • pbr: true on a primitive/mesh material now derives the Patina height map alongside normal/roughness/metalness, and materials carrying the full Patina override set stay batch-eligible (per-texel roughness/metalness maps currently apply on the standalone mesh path; batched lanes use the scalar values — packing NRO onto the lanes is the recorded follow-up).

  • New textureScale on standard material specs (meters each texture tile covers, default 1): divides the automatic real-world tiling everywhere it's produced — batched primitive lanes (initial write, dynamic instance-scale rescale, and the oversized pool) and the standalone mesh/lease paths. An explicit mapRepeat still wins verbatim; textureScale is ignored alongside it and under clamp wrap. Stays batch-eligible.

  • The generated prompt teaches textureScale as the density knob and steers away from repeat (whose box-family semantics are repeats-per-meter, not the absolute counts Savi's three.js prior expects).

  • Bone-attached child entities (attachment: { bone }) are now a horde demotion signal, parallel to explicit IK channels. The horde skinned batch (#6631) replays baked GPU clip palettes and never registers a ModelBonePoseProvider, so once 8+ copies of the same model were in the scene, an attachment target could silently land on the batch — writeModelBoneTransform failed forever and the attached child parked at the world origin. New client-plane draw/bone-pose-required marker (maintained by tome/hierarchy-render-solve from the attachment hierarchy) forwards to the renderer, gates horde admission in createSkinnedRepresentation, and demotes an already-batched visual to the per-character clone via demoteHordeVisualToClone.

  • Renderer plumbing: draw/bone-pose-required op routed through the model handler to setModelBonePoseRequired / applyModelVisualBonePoseRequired, mirroring draw/ik.

  • applyVoxelTerrainEditToChunkEditsState is now O(1) amortized per single-cell edit instead of rebuilding the chunk's whole edits array (filter + per-entry bbox-key strings + full re-copy) on every apply — the quadratic sim-tick cost that killed sustained block-ticking voxel sessions (/zoo water + grass automata: sim ticks 13.9→40.5 ms, worker heap +4.5 MB/s). The apply path keeps a cell-keyed index (WeakMap) beside each edits array it allocates and mutates that array in place: same-cell edits replace their predecessor, new cells append, a cell whose command predates a later region command is moved to the end (preserving "later commands win" sampling). Retained state stays bounded by touched cells.

  • Ownership rule: only arrays allocated by the apply path are mutated. Foreign arrays — replication decode, prediction-rollback restores (which write oplog values into the world by reference), deserialize, region appends — are compact-copied once before first mutation, so prediction/oplog history can never be corrupted through a shared reference.

  • New getAppliedVoxelEditDelta(value): the single command a state value introduced. All TerrainChunkEdits set-hook consumers (block-tick neighbor enqueue, material-lookup overlay, voxel edit fast lane, edit-latency probe) read it before falling back to array diffs — required for correctness with in-place mutation (previous/value share one array) and removes the remaining O(edit count)-per-edit hook work (getNewVoxelEdits id-set build, fast-lane append-shape walk, probe seen-set rebuild).

  • Terrain chunk job inputs snapshot the edits array at creation (jobs/io.ts), so an in-flight job keeps describing exactly what its inputsHash hashed even if more edits land before it runs.

  • F3 prediction overlay: [pred] srv= (and Δ) now read the live server-tick source. The reads went through the interp clock's lastServerTick, whose feeders (onFullsync/notifyServerSnap) were deleted in the netcode merge (#6674) — the clock stayed at its initial 0, so srv=0 and Δ grew forever. Both consumers (updateAndPublishDebugOverlay, catchUpWorkerTickDrift) now read the newest server-oplog tick (base tick after a projection reset), which recordAuthoritativeProjection feeds on every StateDelta.

  • Worker tick catchup now sees real server ticks even before any input acks land (previously masked by Math.max with ack ticks).

  • The horde skinning bake (bakeHordeSkinnedModel) now closes clip palettes into a loop cycle for frame-1 exports — clips whose tracks are keyed from t=1/30 (Blender/Mixamo style) with nothing at t=0. Three clamps every interpolant to its first key inside the [0, firstKey) dead zone regardless of loop mode (WrapAroundEnding only affects cubic tracks), so the old [0, duration] sweep baked frame 0 as a duplicate of the first-key pose and never recorded the wrap bridge: palette loops froze for a frame and snapped at the seam. Dead-zone frames are now baked as the bridge from the wrap pose (end of clip) back to the first-key pose via per-node TRS lerp/slerp — frames[0] ≈ frames[frameCount−1], closing the cycle. Clips keyed from t=0 bake byte-identically to before.

  • Known parity nuance: loop: "once" channels on the horde path show the bridge pose for the first ≤1 bake frame (≤1/30s) instead of the clamped first-key pose; sub-frame and intentionally not special-cased.

  • The idle DRAW/TRI oscillation (e.g. 15M ↔ 25M triangles on a perfectly still village scene) was a gauge artifact, not remesh/LOD churn: renderer.info resets once per frame and accumulates the main pass plus every shadow pass that re-rendered inside the same render(), and the sun CSM far cascades stagger round-robin (cascade i every i+1 frames), so the per-frame pass composition — and therefore the raw totals — oscillates with the camera static. A new shadow-pass frame gauge (extensions/lighting/shadow-pass-gauge.ts) is fed by SunCascadeShadow.scheduleCascadeShadowRenders (each marked cascade) and ShadowAtlas._renderQueuedFaces (each atlas face render), consumed once per frame by the renderer frame loop, and carried on RendererInfoStats.shadowPasses / RendererStatsSample.shadowPasses.

  • Inspector attribution: the Performance tab metric grid gains a "Shadow passes" cell next to Draw/Triangles, plus a footnote stating that Draw + Triangles include every shadow pass rendered that frame and that GPU-culled indirect draws (terrain tile pools, decorations, voxel buckets) report triangles at full CPU-side instance capacity — GPU cull survivors never reach renderer.info, so the totals are an upper bound, not real drawn geometry. The stats-gl header gains a SHDW panel beside DRAW/TRI so the oscillation visibly correlates with shadow-pass count.

  • Telemetry-only — no rendering behavior change. The real GPU load behind the felt ~50fps (all-cascade collision frames in the stagger, per-frame decoration cluster-extent recompute) is tracked separately under the renderer perf work.

  • IK foot grounding no longer demodulates per-tick transform noise into leg motion. Every gate in the foot-grounding state machine was keyed to the instantaneous wrapper Y, so mm–cm frame-alternating noise (netcode correction blending, physics rest jitter) flipped the platform-mismatch verdict, sawtoothed the step-fade weight, and reproduced 1:1 in planted feet — visible as one or both legs shaking on world Y while the surface decided which gate sat near its threshold. World-keyed measures (terrain sample vs the body, plant anchors, the capsule contact plane, the stretch-release hips estimate) now compare against state.referenceY, an exp-smoothed (15/s), lag-clamped (5cm·scale) wrapper Y that snaps on teleport; clip-keyed measures (animated lift above the body) deliberately stay on the raw wrapper Y, where the noise cancels exactly.

  • The platform-mismatch gate carries per-foot hysteresis (±0.05·scale around FOOT_PLATFORM_MISMATCH) — one terrain↔plane verdict per surface, never one per frame.

  • The vertical-stability clock that gates the capsule-plane fallback runs on the reference height's derivative instead of raw dy/dt. At high frame rates, ±2mm alternating noise used to read as ~1 m/s and permanently starve the fallback (feet never grounded on physics surfaces); real motion still trips the clock within a frame via the lag clamp, and a jump apex still cannot engage it.

  • A locked anchor tracks ground-height changes at a bounded speed (FOOT_ANCHOR_TRACK_SPEED, 1.5 m/s·scale) instead of copying the sample verbatim — a one-frame sample step (LOD swap, terrain↔plane branch change) ramps a planted foot instead of teleporting it.

  • Regression tests drive frame-alternating wrapper noise at each gate (mismatch boundary, step-fade edge, plane fallback at 240fps) and a one-frame ground step under a planted anchor.

  • Analytic stars (sky-node.ts) now carry a wide low-amplitude gaussian halo (bright layer, 6× sigma at 0.06 amplitude — the moon's pattern). Stars previously had no analytic glow term and relied entirely on engine bloom, which they almost never reached: at threshold 1.0 / strength 0.15, a 1-2 px sub-threshold core contributes zero, and even an over-threshold peak dilutes to nothing through the bloom mip chain. Bright-layer radiance also raised 1.3 → 2.0 so the top-percentile stars clear the bloom threshold and pick up real bloom on top of the halo.

  • The per-pixel starlight grain over the milky-way band is now footprint-aware: it fades back to the smooth bake as the pixel footprint approaches the finest noise octave's feature size (560/rad ≈ 0.1°, ~1 canvas px at game FOV — at/below Nyquist it could only alias into per-pixel speckle). The factor is mean-1.0, so authored band brightness is unchanged.

  • Moonlight star washout softened 45% → 25%: the default night always has the moon up (moonDirection defaults to −sunDirection), so the heavier cut dimmed every star on every default night.

  • Client spec-sync (spec-sync.ts) no longer consumes a TomeSpec revision before applying it. Previously syncState.revision was recorded before the applySpec try/catch, so a throw was swallowed and that revision skipped forever — on a fresh join (whose snapshot carries exactly one revision) this left the client with no terrain/atmosphere while server-replicated objects rendered fine, until the server happened to bump the spec again. A failed apply now retries the same revision on a 60-tick backoff and self-heals.

  • Server spec-update system now preserves dbVersion when rebuilding the TomeSpec value (mirrors object-api). Dropping it disarmed room-runtime's stale-echo guard (existingVersion: null in logs), letting a late replace:true room.update reconcile a live world down to an older DB spec.

  • Kernel-server fetches to kiln /magic/* (glb-bounds prefetch, server model warm, collider hull extraction) now authenticate with the variant integration API key + x-variant-id; kiln's magic-cdn auth accepts that pair and scopes serving/generation to the variant's app. Previously these fetches were unauthenticated and 401'd on any asset still mid-generation, starving bounds metadata (mis-grounded placement), animated-model warming, and the bounds-driven spec-revision bumps during every build window. Headers attach only on absolute /magic/* URLs when the SDK env is present — browser fetch paths are untouched and the key cannot leak to third-party hosts.

  • createVoxelMarkSampler no longer allocates string-keyed per-voxel Maps (${x}:${y}:${z} key plus two fresh closures per cache miss). Per-stage material memoization is now a flat palette-interned Uint16Array indexed by extent-local voxel position (extent = chunk bounds + margin, full vertical range), allocated lazily per stage and dropped with the job. On mark-heavy chunks the old caches drove jobs-worker isolates to 531MB-2.05GB (measured) and OOMed the renderer process.

  • Surface resolution is memoized per (column, stage). The old cache key included the polyline-interpolated approxY (${x}:${z}:${round(approxY*1000)}), so near-miss values re-scanned entire vertical columns through the recursive stage stack — the O((2H)^k) blowup for k stacked path marks. Each stage is only queried by the path mark directly above it, whose approxY is a pure function of the column, so the column alone is a sound key. The per-mark cross-job surfaceCache (unbounded growth for the lifetime of a definition revision, shared across chunk stage stacks) is deleted; the per-job memo replaces it.

  • Mark application contexts are built once per stage instead of per cache miss; re-entrancy only descends to strictly lower stages, so per-stage mutable contexts cannot clash. Dead samplePreviousMaterial / sampleBaseMaterialId context fields removed.

  • Hard per-job work budget: extent cells x (stages + 1) x 2 — a generous ceiling no correctly-memoized job can reach. A chunk that exceeds it logs one structured [terrain/voxel/marks] warning and degrades to base terrain sampling (no marks) for the rest of the job instead of pinning a jobs-worker isolate forever. Jobs terminate. Extents too large for flat caches (cells x stages > 2^26) sample uncached under the same budget.

  • Tests: pathological chunk (12 overlapping full-height path marks) completes inside the budget with marks applied; memoized chunk sampling matches the uncached sampler voxel-for-voxel on a mixed-marks case; budget exhaustion warns exactly once and falls back to valid base sampling. NPC-AI V1 — engine agents with pathfinding, steering, perception, and locomotion (design: docs/npc-ai-design.md).

  • New properties.npc (world-model params only: speeds, turn rate, vision, hearing, step capabilities, radius, clip overrides). Presence makes the object an agent: no rigid body (kinematic trigger hurtbox by default — kills the per-NPC character-controller cost), terrain-snapped, engine-driven locomotion.

  • New ObjectAPI verbs: moveTo(target, { speed, range, face, avoid, exact }) → { arrived, blocked, unreachable, distance }, flee, wander, face, canSee, nearest, makeNoise + onNoise behavior hook, seconds(n), and the low-level surface findPath, isReachable, navAt, steer({ interest, danger }), groundAhead/wallAhead/gapAhead.

  • Lifetime rule: movement intents decay unless re-asserted each tick (stop-by-not-calling, hot-swap idempotent); face/steer decay independently.

  • Nav substrate: lazy walkable-column grid per place (heightmap heightFromDefinition, voxel column surface, 3d-rooms parsed tiles incl. walk-through doorways, static primitive colliders rasterized — wedges as ramps), chunk islands with union-find (O(1) unreachable), invalidated by terrain-edit version + static-prim signature. Deterministic integer A* (verified bit-identical across JSC/V8), expansion-quota budgeted per tick, entityId-ordered, memoized via the replicated tome/nav-agent component (completedAtTick keeps resim replays observation-stable).

  • Movement: 16-slot context-steering resolver (interest/danger maps; ~50–70ns/agent — no LOD needed) + octree separation + collide-and-slide mover with step-up/max-drop; stuck watchdog (1.2s no-progress → one auto-repath → blocked).

  • Locomotion felt layer: rate-limited facing + idle/walk/run mixer channel with EMA/hysteresis/anti-slide playback scaling (absorbs the deprecated Animated3DCharacterLocomotionFeature machinery), writes DrawMixer without stomping script-authored channels.

  • Perception: canSee (distance² → wide-near/narrow-far cone → single chest-point ray over voxel/rooms/prim geometry — no Rapier dependency), 1.3× lose-sight hysteresis, deterministically staggered re-rays cached in tome/npc-sense. makeNoise → server-only onNoise dispatch (physics-dispatch precedent), scaled per listener by npc.hearing.

  • Script-space game conventions (BUILTIN_MODULES, forkable): builtin/combat (health/damage convention with attacker attribution via hurt events + default juice), builtin/claims (TTL-decay token grants + runCoordinator for a realm:"server" coordinator script — attack tokens/roles/jobs in one keyed surface), builtin/barks (anti-spammed chat-bubble barks).

  • God-mode chips: npc RULES entry + vision-range drag-ring (falloffRangeHandle pattern) + speed/turn/hearing preset menus; npc added to UNDO_ENVELOPE_KEYS.

  • New npc skill (≤3K authored tokens) carrying the FULL verb teaching + the canonical zombie example; the always-on prompt keeps only properties.npc as a breadcrumb and seconds() (platform-wide) — the npc surface is net NEGATIVE inline (~−440 real tokens vs pre-npc baseline). New zoo brains section folded into the Playground zone (x +90 lane): chase-around-obstacles, kiting archer, 12-agent separation, leash, noise/investigation, 60-agent perf pen.

  • New components: tome/npc-agent-cfg, tome/npc-intent, tome/nav-agent, tome/npc-motion (replicate aoi, mismatch-included), tome/npc-sense (replicate aoi, mismatch-EXCLUDED — per-side stagger stamps), and tome/npc-assert-beat (replicate never — the lifetime-rule heartbeat is side-local; absolute behavior-clock ticks never enter replicated state, so per-tick stamps can neither trigger rollbacks nor cost egress; decay grace 2 ticks absorbs the known ±1 server/client behavior-clock skew). Group claim grants live in coordinator entity state.

  • New systems: tome/npc-nav-solver (both, 105), tome/npc-move-resolve (both, 110), tome/npc-locomotion (both, 10001), tome/npc-noise-dispatch (server, 116).

  • New routing spec section — routing: { script, maxPlayers } names a script exporting pickRoom(ctx) that picks the room a joining player lands in (return a roomId string; a name that doesn't exist yet creates that room). maxPlayers is the per-room cap enforced at connection accept; full rooms reject the join and the client re-picks. pickRoom is its own hook kind: once per join, off-tick, async-allowed, non-deterministic-allowed, no ObjectAPI/world access; null/throw falls back to the player's last room or the default room. Compiled via compileRoomRoutingHook; ctx types live in src/tome/room-routing.ts.

  • New builtin/room-routing module for routing scripts: firstOpenRoom(ctx) (last room → most-full open room → fresh room), mostFullWithSpace(ctx), nextRoomName(rooms, exclude?), roomFor(key) (stable key → room name).

  • Storage jobs gain cross-room data access for server lists, queues, and per-server saves: storage:list (prefix), storage:query (where/sort/limit), and storage:lock / storage:unlock — server-side spec code only, same calling convention as storage:get.

  • updateObject (interpreter.ts) re-applied AUTHORED feetPosition/rotation/yaw/scale over LIVE state whenever def-level propertiesChanged was true — any unrelated property edit (visible, material, model, behavior re-save) snapped the entity back to its authored transform. Behavior-swung doors closed on every Savi save; runtime-moved objects teleported home on any def touch. Each transform re-apply is now gated on that key's own diff (prevDef vs def via specValuesEqual); a missing prevDef (snapshot restore, spline re-loft children) keeps the old re-apply-authored behavior.

  • rebindExplicitParentObjects (interpreter.ts) ran on EVERY applySpec and EVERY player join (spawnPlayer), re-attaching every explicitly-parented (parent: field) object and resetting its entire local transform to the def values — {0,0,0} local when the def carries no feetPosition (attachEntityToResolvedParent). A village whose houses were laid out at runtime (setLocalPosition and friends, which don't record into the spec) stacked at the parent origin on the next unrelated edit or join. Rebind now skips children already attached to their resolved parent unless the applySpec diff shows the authored parent/feetPosition/rotation/scale/pivot/attachment actually changed; the player-join path (no prev defs) only attaches not-yet-attached children (its actual job: binding parent: "player" objects to the fresh player entity).

  • terrain/client-build no longer re-evaluates its entire unsubmitted dirty backlog every tick. At connect, extended desktop streaming profiles mark 2,500-4,289 resident chunks dirty at once; Phase 2 then re-ran edit aggregation + the composed-field version walk + inputs hashing for every one of them on every tick while the 64-slot pending budget drained at ~64 chunks/tick — 324 ms mean (494 ms max) sim ticks for ~12-16 s, sim at 2-3 Hz (traced: getComposedFieldChunkVersionsInBounds 5.9 s + chunkKeysInBounds 1.2 s of a 14.2 s capture). Phase 2 now orders dirty chunks by streaming priority (player-proximal first) BEFORE any hashing, evaluates at most 2× free pending capacity per tick, and skips entirely when the pending queue is full. Per-tick Phase-2 cost is O(free slots), never O(backlog).

  • Dirty marks now carry a monotonic per-side stamp (terrain-dirty.ts), and the client build system memoizes each evaluated inputs hash keyed on that stamp: a dirty chunk waiting for pending capacity is hashed exactly once per mark, never once per tick. Any observable input event (edits, field writes, wantedLod retarget, definition install) re-marks with a fresh stamp and voids the memo, so in-flight and queued builds keep hashing exactly what they describe. The "idle loaded place performs zero hash computations" invariant is unchanged and still pinned by tests.

  • getComposedFieldChunkVersionsInBounds iterates min(rect cells, painted chunks) instead of probing every rect cell, and the field-chunk index now stores pre-parsed refs (kx, kz, entity id) so the walk does zero per-cell key parsing or entity-id string building. Output is byte-identical to the dense rect walk (same entries, same rect-scan order — it feeds the terrain inputsHash) on both iteration strategies, pinned by parity-oracle tests; the existing heightmap build golden fixtures pin output/outputsHash stability end-to-end.

  • Physics stable-entity invalidation for a rebuilding chunk moved from "every tick the chunk sits dirty-mismatched" to submit time — once per actual rebuild.

  • Server build system unchanged: it already selects by priority and hashes inside its pending-capped submit loop, so it never had the O(backlog × ticks) shape.

  • tome/fx-compile no longer re-derives every fx entity's compile key — entry source hash plus tracked import-closure hash — on every frame and every resim replay tick just to compare it against the stored key. On an fx-heavy game (DD5 #69, 363 leaked combat fx) that pure key-recomputation measured 4.44 s self-time in a 21.5 s capture (60% of the sim worker, ~205 ms/s) with zero actual compiles the whole window, and 1.65 s of it sat under the tick fn so resim multiplied it. The key is now memoized per entity on what actually invalidates it: the spec.scripts record by identity (every script edit replaces the record — ObjectAPI script writes spread a new one, client applySpec installs a structuredClone; nothing mutates it in place), the FxEmitter.script ref, FxEmitter.version, and a new compiler cache epoch. Steady-state frames do an O(1) identity/version check per fx entity and zero source hashing; the hash work runs only on the frame a script, import, or fx definition actually changes.

  • New getBehaviorCacheEpoch() in tome/compiler.ts: a monotonic counter bumped by clearBehaviorCache/invalidateBehaviorCache. Those resets clear the dependency-tracking cache behind getTrackedDependencySignature, which changes compile keys without the spec's scripts record moving — the epoch folds those resets into the memo's validity check so a cache reset still triggers the same one-shot recompile pass it always did.

  • All previous recompile triggers are pinned by tests with an fnv1a32 call-count probe (fx-compile-memo.test.ts): entry script edit recompiles exactly once, lib/import edit recompiles, FxEmitter.version bump recompiles only the bumped entity, broken scripts fault once and stop hashing while broken, content-identical spec replacement re-keys once but compiles nothing, a resim window replays with zero hashing, and a compiler cache reset recompiles like before. All eight tests fail on the parent commit at the zero-hash assertions (parent: 36 hashes/frame for 12 entities) while every behavior assertion passes on both — semantics unchanged, cost removed.

  • Ported LightAimSchema ("down" | "forward" | PositionSpec, mirroring LightAimSpec in cf-kernel/src/tome/types.ts) into packages/tome-schemas and added the optional aim field to SpotLightSchema and DirectionalLightSchema. The engine shipped aim in #6653 but the Zod schemas never learned it, so any GameSpecSchema parse round-trip silently stripped the field (Zod v3 drops unknown keys from parse output) and validate_spec accepted malformed aim values. Runtime defaults untouched: aim omitted still means the legacy rotation-presence behavior (down until the object has any rotation), resolved in the renderer.

  • Regression tests in apps/cf-kernel/src/tome/__tests__/spec-schema-light-aim.test.ts: aim survives spec parse round-trips (root objects + places), every engine-accepted aim shape validates, no-aim specs stay legacy, malformed aim is rejected.

  • patchAtmosphere / patchTerrain accept every type-distinguishable (patch, place?) call shape: (patch), (patch, "main"), (patch, { place: "main" }), and string-first ("main", patch) (the string is the place, the object is the patch). Normalized by normalizePatchPlaceArgs in object-api.ts.

  • Caller argument-shape errors on spec-mutation methods now THROW a teaching error instead of mutationWarn + silent early return (run_script still reported {ok:true} while nothing changed — the debug-day 5.0 "washed terrain" / "spec adjustments not saving" P0). Converted sites: patchAtmosphere/patchTerrain (malformed patch/place, no-supported-fields), patchPlayer (Zod root-shape rejection, previously warn+notifyDmOnce), definePlace/updatePlace/deletePlace (invalid : place ids), setDefaultPlace (empty id), enterPlace (missing options, invalid place ids).

  • State/environment guards (entity missing, place doesn't exist, physics runtime absent, client/server gating) keep mutationWarn — and the warn → run_script result-logs round trip now has regression coverage in script-dispatch.test.ts.

  • @spawn/tome-schemas: added the missing { type: "grid", cellSize?, radiusCells?, axes? } AOI variant to engine.networking.aoi, matching AOIConfig in types.ts. The engine's default spec ({ type: "grid", cellSize: 128, radiusCells: 4 }) failed its own schema, so every validate_spec carried permanent "engine.networking.aoi: Invalid input" noise.

  • api.job() and api.triggerPurchase() now mint their request ids from a side-local, non-replicated counter (TomeRequestIdCounterResource, mirroring the lifecycle lane's lifecycle-${n} precedent) instead of this.uniqueId() on the player's replicated tome/id-seq. The job/purchase registries are server-only and the ids never need cross-machine minting equality, but jobs commonly fire from timer/lifecycle contexts whose fire ticks are structurally skewed across client and server — so every storage heartbeat (runInSeconds loops calling storage:set/storage:list, the most common Savi storage idiom) was a guaranteed recurring tome/id-seq mispredict on the player (the Δp50=2 / Δmax=3 / 1.4% pattern from the 5.0 debug-day report).

  • The boundary is now named in code (above nextOwnerIdSeq in object-api.ts): replicated seq (TomeIdSeq) = ids that cross the wire or name shared entities (spawn/joint/structure — unchanged); side-local counter = request/correlation ids one side mints and the other never re-mints.

  • The counter is a registered predicted resource, so discarded predicted timelines re-mint the same request ids on replay; it resets with the world in resetTomeWorld.

  • New Session Lab grind scenario wrapper-embed/id-seq-heartbeat-grind.json: the distilled heartbeat repro (3s leaderboard set+list, 15s autosave), asserting zero post-warmup present/unknown mismatches.

  • Fixed the analytic star halo truncating at its grid-cell border (sky-node.ts starLayer). The wave-1 halo (sigma = 6× core, ≈ 0.27 cell at the bright layer's 0.0015 rad sigma floor) reaches past the per-cell evaluation boundary, so the brightest stars showed a hard square clip edge where the halo met the cell border. The footprint now fades to zero across the outer quarter-cell (smoothstep on cell-local border distance — branchless, no select), so brightness lands at exactly zero where evaluation hands off to the neighbor cell. Neighbor-cell sampling was rejected: the grid is 3D over the view direction, so honest cross-border halos need an 8–27 cell neighborhood per sky pixel per layer (also paid by the IBL capture) for a 6%-amplitude tail.

  • Visual-constant sanity notes: star jitter is ±0.15 cell, so every peak sits ≥ 0.35 cell from any border — the fade band (0 → 0.25 cell) never touches a core or inner halo, only the outer tail of the few stars bright enough for their halo to clear the night floor (halo at the border carried up to ~0.4 of its peak ≈ 0.1 radiance against a ~0.01–0.1 floor — the visible edge). Bright layer: fade band ≈ 8 mrad ≈ 8 px at game FOV — a smooth multi-pixel rolloff, ~0.9 halo-sigma from a centered star. Faint layer (halo 0): fade only matters in the pixel-clamped-sigma regime (low res / wide FOV), where it stops cores from drawing dashed cell borders; at the 0.0011 rad sigma floor the core is at e^-6 by the fade band, unchanged. Applied to the whole footprint (core + halo) — one multiply, and the invariant is uniform: per-cell star evaluation reaches zero by its cell border.

  • Added sky-node-wgsl-dominance.test.ts: transpiles the production sky background node through the real WGSLNodeBuilder and asserts no execution path reads a node var before assignment (the select/toVar miscompile detector, now covering the sky shader).

  • Time-sliced terrain chunk batch builds (features/terrain/jobs/chunk-build.ts): terrain/chunk-batch-build no longer builds its 8-chunk batch as one synchronous task. Heightmap/rooms batches build one chunk at a time and yield the jobs worker's macrotask queue whenever the running slice exceeds TERRAIN_CHUNK_BATCH_SLICE_BUDGET_MS (8 ms), so an expensive custom heightAt/materialAt script (~2.9M noise evals per batch in the reported game) produces ~one-chunk slices instead of 200-250 ms worker stalls. Cancellation is checked between slices (a canceled job stops burning the worker). Voxel batches keep the fused cross-chunk batch mesher. Outputs are bit-identical — slicing only moves task boundaries.

  • Walk-speed streaming lookahead (features/terrain/streaming.ts): LOOKAHEAD_MIN_SPEED 8 → 4 m/s. The default walk speed is 6 m/s, so walking players previously got zero lookahead and every 32 m chunk-boundary crossing started a cold 15-30 chunk build burst exactly when the new ring became visible — the "fps drop every 5-10 s while moving" cadence (32 m / 6 m/s ≈ 5.3 s). Walkers now pre-request the ring 1.5 s (9 m) ahead of the crossing, on both client and server streaming.

  • Renderer per-frame chunk install cap (renderer/handlers/terrain.ts): height-chunk visual installs (pool slot binds + height/blend texture-layer writes) are capped at HEIGHT_CHUNK_INSTALLS_PER_FRAME = 4 per collect. Pending installs persist across frames (FIFO) instead of landing as one ingest train — a batch arrival no longer stacks multi-ms of adapter work plus that frame's node-material builds onto a single rAF. Despawns and component removals still apply immediately; only visual installs ride the queue. Place-render-config re-keys ride the same cap, so a material-config edit updates resident chunks progressively over a few hundred ms instead of one heavy frame.

Named follow-up (deliberately not shipped here): a batched noise sampling API for custom terrain generators (fill a typed array per chunk per noise field instead of per-sample option-object calls) to kill the ~120k allocations/chunk feeding job-worker scavenge storms and the render-worker major GC. The seam is the single heightmap pipeline resolution point (TerrainConfigLike in chunk-build.ts + terrainNoiseRuntime in program/noise.ts); it needs a Savi-facing vocabulary decision, not just plumbing.

  • F3 perf tab: the systems list (text overlay + advanced panel) now leads with the already-collected recent-1s window (recentMsTotal as ms/1s, recentMsMax as worst run, recentRuns) and sorts by it; the session-cumulative average stays as a secondary column. Previously rows showed msTotal/runs — a session average that diluted burst systems (e.g. terrain/client-build at 36-207ms/run while walking) to invisible while the unattributed "Frame Work" gauge climbed.

  • New frame-work attribution: "Frame Work (1s)" (rolling 1s window of sim-worker CPU, same clock basis as system recents) is broken into named sub-rows — ingest [netIngest], sim (main), resim, input, interpolation, render egress (ecs-sync), renderPrep (rest), messages (decode/apply) — plus an honest other remainder (Frame Work minus attributed, clamped at zero) covering GC, scheduler overhead, and egress posts. computeFrameWorkBreakdown in engine/client/perf-debug.ts is the pure attribution function; the worker tracks the 1s frame-work and onmessage-busy windows in runtime-worker.ts.

  • Restored the singleplayer mode deleted wholesale by the #6674 netcode merge, rewired onto the new room protocol instead of resurrecting the old host.ts ctrl channel:

    • glue(features, "singleplayer") is back: server-only systems run client-side (preUpdate→netIngest, postUpdate→interpolation, replication-phase dropped, client wins name collisions).
    • Mode resolution flows from the spec again: restored NetworkingModeTransitionResource + spec-sync syncNetworkingMode; the runtime worker's transition handler reconfigures the live client via client.reconfigure(glue(features, side)).
    • Prediction is a real mode gate now: singleplayer sets ClientPredictionEnabledResource=false (mismatch comparator, oplog capture and resim are all gated behind it — zero mismatch ticks, zero resims, structurally). Local input apply and client physics stepping treat the singleplayer client as the authority instead of early-returning when prediction is off.
    • State deltas are dropped wholesale on singleplayer clients (applyWireMessage gate) AND the room projection is suspended (setProjectionActive(false), the same protocol used for hidden tabs) so the server stops streaming them at all. The socket stays for control traffic.
    • The spec rides the new Control opcode: ControlToClient gains tome.spec.push, tome/spec-sync-server pushes revisions through the restored TomeSpecPushNotifierResource into every connection's control outbox, and the worker's applyWorkerSpecPush applies them (accepts when the world is singleplayer or the pushed spec declares it).
    • Singleplayer ↔ multiplayer transitions work live: entering suspends the projection, sweeps stale remote players and clears prediction state; leaving resumes the projection, which makes the server answer with a resetProjection snapshot that rebases the world (the new stack's fullsync).
    • Restored the three authority remaps (getWorldMode in object-api/query-utils/interpreter: singleplayer client → "server" for mutation guards), the physics hasAuthoritativeSpec short-circuit, ensurePlayerPhysicsComponents on the local player, the per-tick stale-remote-player sweep, and the networking.mode bullet in the patchEngine docs.
    • Server-rails jobs ride the control channel too: with server systems running client-side, builtin tome jobs (storage:*, llm:*, sparks:claim-verify) were landing in the client job pool, where storage adapters and credentials don't exist — saves went to IndexedDB instead of the room's cloud documents and llm calls threw on stub envs. withSingleplayerJobForwarding wraps the client JobsHandle so builtin tome/run-job submissions on a singleplayer client send RoomClientOpcode.JobRequest (ref-correlated) instead; tome/job-forward-server executes them on the room's normal job pool and replies with a job.response control message. The forwarded token settles through the normal JobsHandle surface, so both api.job() callbacks (tome/job-response) and direct system submitters (terrain-edit persistence) work unchanged, with a client-side timeout so a lost reply fails loudly instead of hanging the behavior. Custom spec jobs stay local; multiplayer clients never forward (per-submission gate on the spec's networking mode), and the server rejects forwarded jobs from non-singleplayer rooms.
  • Terrain decorations honor their spec contract again — the renderer-deslop GPU consumer (decorations.ts) had silently dropped typed, skill-taught fields while Savi kept writing them into every spec:

    • sink is back: card geometry seats into the terrain by sink × height (sprite default 0.15 per the documented contract; negative still floats butterflies/fireflies). Primitives gained the same seating (sink added to DecorationItemPrimitive, default 0.1 of shape height) — fixes decorations hovering above hills where the placement sample diverges from the rendered mesh on slopes; composes with the content-rect anchor so the content's bottom row is cropped by the ground ("crop with slight sink").
    • height/width are per-instance ranges again: a world-stable hash2D(instanceXZ) positionNode (same recipe as the primitive variation path) sizes each card in [min, max] instead of collapsing everything to the midpoint — restores the top half of every authored size distribution (the "decorations are much smaller than before" report). Sprite width falls back to the authored height range, not a [0.3, 0.5] constant. Sprite randomRotation is honored (per-instance yaw in the same node). Shared cull bounding sphere inflated by the max variation factor.
  • No loading placeholder: sprite decoration meshes stay visible = false until their texture binds AND the alpha-content rect resolves (the measurement always resolves — failures report null) with the UV anchor applied. Cache hits reveal synchronously; the never-ready case stays hidden instead of drawing bare white crosses that take over the landscape at density.

  • New decorations-card.test.ts pins the layout/sink/variation contract and the texture-gated reveal flow (sync, late-bind, null-rect, no-provider).

  • Lifted the hard DPR-1 renderer cap: MAX_RENDERER_PIXEL_RATIO is now 2 (the absolute ceiling the host reports), and the device's quality tier owns how much of the reported DPR it renders at via the new LightingQualityConfig.maxPixelRatio knob — desktop tiers (high/ultra) take 2, mobile-class tiers (low/medium) stay at 1. The renderer worker applies the tier clamp at init and on resize; the host (getDefaultRendererPixelRatio) just reports window.devicePixelRatio capped at the ceiling. Mobile and tablet devices land on medium/low through guessLightingQualityTier, so phones keep rendering at CSS resolution.

  • Perf note — approved trade: HiDPI desktops (Retina Macs, 4K displays at DPR 2) now fill 4× the pixels of the old DPR-1 path (2× per axis), which roughly doubles fill-bound GPU frame cost on those displays. This was approved as a deliberate quality-for-fill trade on desktop-class GPUs (debug-day 5.0 task #53); low-end desktops are protected by the tier ladder (software rasterizers land on low → DPR 1).

  • History: this lift first shipped inside the sky v2 PR (6b9e7418d, dcde71bea) and was reverted in review (4ab67228e) as an unmeasured rider — "the lift can return as its own measured PR". This is that PR, re-landing the reviewed tier-knob design with the approved values (the earlier attempt's high-tier 1.5 becomes 2 per the approval: DPR 2 on all desktop tiers).

  • Sun cascade scheduling (SunCascadeShadow.ts): the force-all-cascades-on-any-sun-motion epsilon (1e-9 squared chord — below one texel of every cascade) is replaced by two thresholds derived from the actual cascade texel geometry in fitCascadeDepthRange. Sub-texel moves (below min(texelWorld / shadowCameraFar) across cascades) are ignored and accumulate; smooth motion (an animated timeOfDay cycle) rides the existing round-robin stagger, so every cascade refreshes within its period instead of all 4 ultra 4096² cascades re-rendering every frame; only a single-frame jump past ~8 texels on the most sensitive staggered cascade (a discrete timeOfDay change) forces a same-frame full refresh. On an ultra rig with an animated sun this drops sun shadow passes from 4.0/frame to the stagger's ~2.1/frame average.

  • Sky day/night + cloud gates (sky-node.ts): the night stack (moonlit wash, moon disc maria/craters, milky way, analytic stars, starlight grain — ~13 noise octaves plus the star hash chains per pixel) ran branchlessly at noon multiplied by zero; it is now behind a uniform-flow If (nightVisibility > 0), and the cloud layer (~8 octaves) behind If (cloudsEnabled > 0.5). Every expression crossing a branch boundary is materialized as a top-level toVar (the select/toVar r0.184 lowering hazard); a new WGSL structure suite pins that all entry-body noise calls are if-gated and the dominance analyzer stays green. Pixel output is unchanged — the gated terms were exactly zero whenever the gates are closed.

  • Cloud FBM octaves now come from the lighting tier (sky-quality.ts cloudBaseOctaves/cloudDetailOctaves): low 3+2, medium 4+2, high/ultra keep the original 5+3.

  • GPU timestamp timing is inspector-gated (debug/gpu-timing.ts, renderer.ts, renderer-host/browser-init.ts): the renderer worker's StatsProfiler was created with trackGPU: true unconditionally, so three attached timestampWrites to every render/compute pass (per-pass Metal counter-sample buffer allocations + validation in the GPU process) for every player with the HUD closed. The worker now keeps backend.trackTimestamp off by default and flips it via a set-gpu-timing message wired to the renderer inspector's stats-HUD embed signal; while active the frame loop resolves the timestamp queries so the HUD's GPU/CPT numbers are live, and disabling zeroes the last resolved durations. Consequence: the 15s perf-telemetry rollup carries gpuFrameMs only while an inspector is open, and the frame-budget guard sees gpuMs: 0 (its documented timing-unavailable state) unless someone is measuring.

  • engine-reference/ is now version-matched to the room's kernel. The version catalog (scripts/publish-versions.ts) carries a per-semver docs.engineSource URL; kiln's /api/studio-chat/engine-source accepts ?engineVersion= and resolves it through the catalog, reading the artifact private-first from the spawn-engine bucket (the public bucket stopped receiving engine-source artifacts on 2026-05-08 — every read through the public manifest chain served the May 8 snapshot since).

  • Skew fails loudly end to end: a pinned engine version that doesn't resolve in the catalog is a 404 naming the version — never a silent serve of some other build's source — and Savi's grep/view of engine-reference/ reports "engine-reference/ is unavailable" with the reason instead of "No matches". A missing reference must never read as "the engine has no such API"; that misread is how the debug-day day/night session went sideways.

  • cf-studio-chat caches engine source per engine version (small LRU, the decompressed maps are ~25 MB each) instead of one global map shared by every app on the DO.

  • Atmosphere read-after-write (debug-day 5.0 task #76, C1). patchAtmosphere writes the override layer + replicated components, never GameSpecResource, so a same-script getSpec("places.*.atmosphere") read-back returned the OLD atmosphere and produced false "my patch was rejected" spirals (patchTerrain, by contrast, updates the spec resource synchronously). getSpec now reads the atmosphere portion through PlaceAtmosphereMergedResource (specWithLiveAtmosphere in object-api.ts) — authored base + runtime patches + live cycle hour — so getSpec and getAtmosphere() agree and a read immediately after a patch reflects the patch. The chosen read model: getSpec is the LIVE spec (the persisted spec converges to the same atmosphere via the recorded patchAtmosphere mutation; the live view additionally carries runtime cycle-anchor state and the live timeOfDay hour). Non-atmosphere reads are unchanged. getAtmosphere(place?) also gained an optional place argument so a patch targeted at another place can be read back symmetrically.

  • physics.body enum validation (C3). The spawn validator only checked body presence and the property writer passed any string straight through to the physics layer as bodyType — a typo'd body spawned visibly fine with no collider. PROPERTY_VALIDATORS.physics now validates the body against the accepted enum ("none" | "static" | "dynamic" | "kinematic" | "character" | "vehicle") with a did-you-mean map for the names models keep guessing ("fixed" → "static" — the RAPIER name — plus case fixes and rigid/kinematicPositionBased variants), and propertySetters.physics throws the same teaching error on update paths (setProperty/setObjectProperty/batch) instead of silently removing physics. Spec application at load keeps its own non-throwing path — existing persisted specs with bad bodies still load.

  • spawn parent-in-properties auto-hoist (B1). The teaching throw (#6563) provably does not stop wisp retry loops — lanes burned multiple versions regenerating the same mistake. Per the accept-obvious-call-shapes philosophy, spawn() now hoists parent out of properties to the top level with a mutationWarn and spawns what was meant (recursing into inline children, since placement validation runs on the whole tree before child recursion). A conflicting top-level parent wins; a non-string nested parent is dropped with a warning. Other misplaced spec fields (behavior, tags, …) keep the teaching throw.

  • NaN/∞ scrub on both sides of the bloom seam (post-processing.ts scrubFiniteHdr): the scene texture feeding the bloom threshold/downsample and the bloom composite back in are clamped branchless (min/max, no select/toVar — the r0.184 lowering hazard) to finite non-negative with a HalfFloat ceiling (65504). Previously the only floor sat AFTER bloom, so one NaN pixel passed the threshold high-pass intact, the mip pyramid smeared it across every mip, and the bilinear-upsampled widest mip washed the entire frame flat (the white-world session: app 6bf82e5c, dumps 868a9cc9/5c0dac3a).

  • Velocity-aligned sprite orientation (fx-gpu render.ts + CPU particles.ts): the screen-projection angle guard is now branchless — mix onto the screen-x axis via step(1e-5, length) BEFORE the atan, so atan2(0, 0) (indeterminate in WGSL, NaN on some drivers) is never computed for stalled/floor-stuck particles. The old select() only discarded the value after both operands were evaluated.

  • Fog color derivation when density is authored without a color (atmosphere-sync.ts deriveSkyFogColor, was deriveProceduralFogColor): gradient/color skies hand over their authored horizon/sky tint; procedural skies anchor to an authored horizonTint when present and otherwise use a neutral dark-leaning haze (the old default was a near-white daytime ramp — a white-world generator at typical densities with affectsSkybox: true). The no-sky fallback drops 0xa0a0a0 → 0x4d525a. Explicitly authored fog colors are untouched.

  • The inspection-render note (scene-view-capture.ts) now states that engine bloom and the look pass are absent from camera/frame captures, so a washed player viewport vs. a clean inspection render reads as a post-chain difference instead of two contradictory truths.

  • Camera smoother regime gear-shifts now blend instead of snapping: CameraSmoothEntry gains intervalStepTarget (the hysteresis-quantized 1x/2x/3x regime from smoothingIntervalStep), and tick() eases the effective intervalStep toward it with a 30ms half-life (~100ms perceived transition). Previously a 1x→2x promotion hard-doubled the smoothing half-life in a single frame (per-frame convergence ~40%→23% at 120Hz), felt as the camera abruptly going sluggish.

  • Mouse-driven orbit center and distance converge ~2x faster (half-life factor 0.25 vs 0.5) in the orbit-with-mouse branch only. Yaw/pitch was already renderer-direct, but the orbit center/distance smoothed at the sim-follow alpha, so the rendered position trailed an instant rotation by 2-3 frames. Sim-authoritative (zoomTo), first-person settle, non-interactive/cinematic, and FOV smoothing are untouched, as are the EMA weight and hysteresis thresholds.

  • Regression tests in apps/cf-kernel/src/engine/camera/__tests__/camera-smoother.test.ts: a cadence degradation passes through intermediate alphas over multiple frames and still settles into the new regime; orbit center/distance first-frame convergence is the fast alpha (~0.86), not the follow alpha (~0.63).

  • Stranded-sleep class killed (rapier): any authoritative position write that MOVES a dynamic body now clears PhysicsBodyState.status on the wire (clearReplicatedSleepStatus) instead of re-asserting stale sleep. Sites: syncPhysicsBodyToComponents (the external-transform funnel: teleports, terrain-anchor re-hoists, hierarchy-solve), syncFeetPositionPhysics (both the live-body path and the disposed-body respawn window, which now also reconciles BodyPosition with the authored feet write), and body recreation (initializeBodyState only honors born-asleep when the feet/center pose is coherent; an incoherent pair is born awake at the authored feet position with the status bit flipped). The write sites are mode-both/deterministic, so both sides derive the same wake — parity-safe by construction. Previously a slept car teleported by a terrain edit was woken then immediately re-slept from the stale replicated bit: a dynamic vehicle asleep in mid-air, permanently.

  • Rapier wheel control lanes (parity-war law): setWheelEngineForce/Brake/Steering now route through setRapierVehicleControlLane — the control lands in replicated PhysicsVehicleConfig.wheelStates (engineForce/brake/steering), is applied to the live controller for same-tick effect, and is re-seeded into recreated controllers (seedVehicleControlLanes at creation, including resetVehicleControllerState during resim). The writeback publishes the held lanes from the controller so they round-trip exactly (f32). Wake gate mirrors mantle's: wake on lane change or while nonzero engineForce is held, clearing the replicated status bit (throttle on a sleeping chassis used to be silently dropped). Tuning setters (setWheelFrictionSlip/suspension knobs) similarly land in PhysicsVehicleConfig.wheels[i] instead of a live-controller poke that the next sync clobbered.

  • Vehicle suspension self-exclusion: vc.updateVehicle now receives a filterPredicate excluding the vehicle's own entity tree (chassis + Tome descendants, entity-keyed so static children surviving destroy/recreate churn stay excluded) plus EXCLUDE_SENSORS. Wheel rays no longer ground the car on its own floor/panel colliders or on trigger zones.

  • Primitive child physics default: p.box/p.cyl/etc. defaulted every child to physics: { body: "static" }; children of a dynamic/vehicle-bodied parent (resolved up through pass-through pivots) now default to NO physics body. The static default stays for world-building under static/non-physics parents; explicit physics opts always win. The vehicles/turrets skills' taught physics: false workaround is deleted (the canonical car was one omitted token away from ~40 static panels nailed inside the chassis).

  • Vehicles join collider streaming: awake vehicles (PhysicsVehicleConfig, non-static, status !== 1) are terrain-collider anchors regardless of player AOI range, with the same velocity lookahead players get (driven vehicles already streamed as the session's control target; sleeping vehicles don't integrate, so they need no collider until a now-replicated wake path fires). Bounded by nature: vehicles are a handful of hand-authored objects per game, unlike generic dynamic bodies, which keep the existing AOI-range bound.

  • Vehicle chunk-rescue (loud): terrain/chunk-rescue extends to vehicles — below the ECS terrain surface, the chassis is snapped above it with downward velocity clamped. Containment only: no forged grounded/contact state, and repeated fires emit the missing-collider diagnostic ([terrain/chunk-rescue] warn with side/entity/place/chunk), matching the honesty contract of the CC rescue fix.

  • Tests: engine/physics/__tests__/stranded-sleep-wake.test.ts (re-anchor wake, no-move keeps sleep, born-awake/born-asleep recreate gate, sleeping-chassis throttle wake, two-world lane seeding, suspension self-exclusion), tome/__tests__/terrain-anchor-sleep-clear.test.ts (the float repro through the real setProperty("feetPosition") anchor path + respawn window), tome/__tests__/primitive-child-physics-default.test.ts, engine/features/terrain/__tests__/vehicle-streaming-anchors.test.ts, engine/features/terrain/__tests__/vehicle-chunk-rescue.test.ts.

  • Client terrain-collider readiness is now content-realized (debug day 5.0 task #88 — the client mirror of the collider-parity family). The client readiness gate (client-collider-state.ts) used to equate "the physics runtime has ≥1 collider handle for the chunk entity" with "the chunk's geometry is current": a handle whose shape predated the latest install — mantle's deliberate keep-stale-over-hole window with a stranded replacement payload, a torn config/payload pair after an authoritative adopt moved the slim PhysicsBodyConfig under the local payload, any stale install — passed the LOD0 anchor gate, physics ran against wrong ground, and the predicted CC free-fell 1–3 gravity ticks per snapshot at coordinates where the server CC stood grounded (the dump-#88 duty cycle: grounded srv=true/cli=false every frame, deltas exactly k·(−g·dt), ~700 ms/s resim, permanent within episodes). Readiness now requires the runtime to realize the recorded install's CONTENT identity: rapier compares the handle's collider signature against terrainChunk:<payload signature>; mantle handles record terrainContentSignature at collider creation and the refresh compares it against the install. Outputs now carry the realized payload signature (setClientTerrainColliderOutput({ ..., contentSignature })).

  • Stale installs re-request their build instead of stranding. An output-compatible chunk that fails content realization for 3 consecutive refreshes loses readiness, emits a deduped [terrain/client-collider-stale] diagnostic (place/entity/chunk/lod/inputsHash/expected-vs-realized content), and enqueues a rebuild re-request. The client build system drains these before Phase 2, voids its installed-hash short-circuit for the entity (the "this content is current" claim is exactly what stranded the chunk), and re-marks it dirty — the rebuild rides the normal budgeted Phase-2 scan; no new scan loop.

  • Airborne-vs-height parity probe (terrain/client-collider-parity-probe, client-only, every 5 ticks on locally controlled CC entities, skipped during resim replays): when the predicted CC reports airborne+descending for 3 consecutive samples while the client's own height sample says ground is at the feet (−0.1 m..+0.3 m band — physically impossible when the collision world matches the sampled terrain; this dump's exact signature, previously silent because the 0.1 m chunk-rescue threshold is never reached before a correction re-adopts), it emits a deduped [terrain/client-collider-parity] diagnostic naming chunk/entity/hashes plus an explicit chunk-vs-primitive verdict (chunk collider bookkeeping claims-current ⇒ primitive-collider suspect; stale-or-missing ⇒ terrain-chunk suspect — the missing telemetry that settles the 60/40 question per episode), and force-rebuilds the chunk's collider through the same re-request drain.

  • Mismatch classifier base-contamination guard (finding B, display/accounting only): OplogBuffer now tracks which base rows were patched in place by a rollback correction (setBaseComponent/replaceBaseEntityComponents mark; rebase promotion, compaction folds, and full reseeds clear). The skew search never credits a match against a correction-adopted base sample — the adopted value IS the server's value, so matching it is a server-to-server compare. A genuine never-converging parity bug against a static server now books [drift] instead of vanishing into [skew] while resim burns 700 ms/s. Client-provenance base samples (initial baseline, compacted/promoted client writes) still credit skew — the canonical client-leads lag shape is unchanged, and the drift-must-page guard stays green.

  • patchAtmosphere client/server parity (object-api.ts): a predicted client call did its read-modify-write against the side-local PlaceAtmosphereOverridesResource map — which only contains patches that side ran this session, never overrides that predate the client's join — and then rebuilt the replicated TomePlaceAtmosphereOverrides component from that subset with no authority guard. Every predicted patch (a mode: "both" behavior dialing one look param) stripped the room's override residue client-side for ~1 RTT until the server's authoritative component clobbered it back: the 5.0 Post-Processing Lab "renders correctly, then incorrectly shortly after" flip, and a guaranteed parity violation in any room whose override layer predates the client's join. The client now re-baselines its side-local map on the replicated component (the same priority atmosphere-sync uses when merging) before the merge, so both sides compute the identical next-override map for the same patch and the authoritative echo confirms the prediction instead of clobbering it. During resim the component is restored to the authoritative baseline before inputs replay, so replays re-derive the same convergent map. Composes with the window-3 semantics: explicit-null tombstones ride over the replicated baseline, cycle anchors survive untouched patches, and a server-side clearAtmosphereSessionOverrides (revert's replace: true) supersedes the client's stale session map on its next patch instead of being resurrected.

  • Server terrain collider presence parity (PhysicsEngineFeature): the per-place step gate on the server now counts terrain colliders that verifiably exist in the rapier runtime (claim + realized handle after the static sync), not raw ECS TerrainChunkCollider components. The ECS count lies exactly when it matters — claims survive a physics-world corruption rebuild and payload staleness — so the old gate kept stepping character controllers on a world with no ground (pure ballistic CC + chunk-rescue = the standing-still mispredict storm, naruto dump 3f17d056). Claims the runtime cannot realize for 2 consecutive ticks are re-requested via requestServerTerrainChunkColliderRebuild (artifacts cleared so the inputsHash short-circuit can't dismiss the retry as spurious, dirty mark set) with a rate-limited [physics/terrain-parity] warn. Claims without a body config (air chunks, 2D places) count as-is. Mantle places keep the ECS count (no WASM corruption class there).

  • terrain/chunk-rescue is loud and honest (terrain-systems-shared.ts): the rescue no longer forges grounded: true — with the taught isGrounded()/gravity script pattern the forged flag reset script gravity every rescue and manufactured a stable 6-tick server limit cycle (4-of-6 mispredicted ticks, ~100 ms/s resim, forever, while standing still). It now clamps position and downward velocity only; grounded belongs exclusively to the character controller's real contact test. Repeated fires for the same entity emit a [terrain/chunk-rescue] warn with side, entity, place, chunk coord, and fire count — the missing-collider discriminator every dump of this family needs (it previously fired 5×/s for minutes in total silence).

  • Prediction resync hard-adopt (runtime-client.ts): when reconciliation is impossible — mismatch older than MAX_RESIM_TICKS (cap_exceeded) or repeated missing authoritative state — the client now hard-adopts the server's newest authoritative snapshot using the join/place-transition machinery: rollback-only resimulation (authoritative apply + full physics resync + oplog reseed), input history dropped, mismatch tracker cleared, prediction baseline recaptured wholesale at the next commit. Previously markPredictionResyncUnavailable was a debug log and nothing else, so a >45-tick-behind server meant corrections were never applied: server teleports, stall recoveries, and wedged-server corrections all read as "nothing happened" (dump c548601c, "Savi can't move me"). 1 s cooldown between adopts prevents rubber-band thrash on permanently-behind servers.

  • Mismatch display canonicalizes codec-dropped boolean flags (prediction-debug.ts): decoded-absent flag keys vs local booleans render as false, never undef — ceiling srv=undef cli=true was repeatedly misread as a client-only component leak. Decoded-absent vs explicit false no longer emits a spurious detail line. Codec round-trip shape for all flag combinations is pinned by test.

  • NPC gait is now a truthful motion signal. writeAgentMotion derives speedEma/gait from the entity's actual per-tick position delta (a lastX/lastZ anchor recorded in NpcMotion, riding the wire with the rest of the component) instead of the locally commanded step alone. On the client a remote NPC moves by replication while its side-local NpcAssertBeat is stale — the intent correctly decays to idle (THE LIFETIME RULE is untouched), but the old local-step derivation read planarSpeed 0 and starved the gait to "idle" while the NPC visibly walked. Observed deltas beyond DISCONTINUITY_SPEED_FACTOR (4×) of the agent's command ceiling fall back to the local step, so teleports and place moves never read as a sprint.

  • Removed the skinned renderer's silent first-clip substitution — on both animation paths. A channel naming a missing clip used to play the model's first clip (per-character mixer: first usable clip under a cloned name; horde batch: substring scan then baked.clips[0]) — the engine picking an animation on its own. Now both paths follow the same law: exact match, else a genuine name variant via clip-name-resolve.ts (shared/prefixed name tokens like "Walking"/"Armature|Walk" for "Walk", or the locomotion families idle/walk/run e.g. "Sprint" for "Run"), else hold pose (clone path: no layer is created; horde path: the bind-pose anim-param sentinel). Batch and clone agreeing here keeps the batch<->clone demotion seam from popping between "first clip playing" and "held pose". The model-clip-not-found diagnostic fires on both paths and reports the resolution outcome (resolved in data, "holding pose" vs "playing the matching clip" in the message) so Savi sees exactly what happened, plus the real clip list as before; the horde path dedupes it per batch, not per instance.

  • The model-spec auto-play contract (_default with no clip name plays the model's first usable clip) is unchanged on both paths — but it now belongs to whole-body layers only: a masked channel with an empty clip name is a no-op again (no layer, no diagnostic), as it was before variant resolution. Auto-playing an arbitrary clip on a body-part mask would be the engine deciding an animation. Masked channels naming a real-but-missing clip get the same variant resolution as unmasked ones.

  • Tests: replicated-movement-with-stale-beat must read gait "walk" (the follow-NPC repro), teleport-discontinuity stays idle, variant resolver matrix, hold-pose-instead-of-first-clip on the clone path and the horde path (anim-param sentinel + per-batch diagnostic dedup), variant-resolution reporting, the horde empty-name auto-play contract, and the masked empty-name guard.

  • Self-reaping one-shot fx (debug day #69 — 363 leaked combat fx ≈ 96 ms/frame of CPU particle tick in the render adapter). Both particle backends already implement the wind-down rule, but only renderer-side: the authoritative object lived forever. New deriveFxProgramCompletionSeconds (tome/fx-utils.ts) mirrors that predicate statically — a finite upper bound on when an effect is provably finished (burst-without-every, closed windows, death-coupling chains; null for unbounded rates, repeating bursts, persistent/lifetime-less populations, coupling cycles). The new server system tome/fx-reap despawns finished one-shots through the same cascade-destroy path stopFx uses. Eligibility is strict: runtime-spawned (TomeSpawnedBy), no behavior, no spec entry. Pause holds the clock; resume restarts it (matching backend effect-time semantics); version bumps re-derive without resetting.

  • Runaway caps with graceful cull. Sim-side: FX_RUNTIME_OBJECT_CAP = 128 live runtime-spawned fx objects per room; on breach the oldest eligible effects are culled first and ONE deduped diagnostic (60 s window) lands on the runtime-log + DM rail. Renderer-side: CPU_MAX_FX_POPULATIONS = 256 total live fx-native CPU populations (the per-population fixed tick is what melted the frame — ~60 µs each); the CPU backend sheds the oldest fx effects on breach (legacy emitters/bursts exempt) and reports once through the new fx-population-cap engine diagnostic (allowlisted; counts ride in data so the content-signature dedupe collapses repeats).

  • Durable behavior parking (the "stuck erroring orb"). Behaviors already fault-and-park on the first thrown error, but every applySpec cleared all faults — a permanently broken script resurrected and re-errored after every edit anywhere in the game. New strike ledger (TomeBehaviorRepeatFaultsResource): BEHAVIOR_PARK_THRESHOLD = 3 consecutive identical errors parks the entity until one of its own scripts (or their tracked lib dependents, via the set invalidateBehaviorCache now returns) actually changes. A different error resets the count; game reset clears all parks; a runtime-log entry tells Savi how to unpark.

  • God-mode entry no longer falls back to the world origin when the player's WorldFeetPosition hasn't resolved yet (toggling right after join). resolveGodSpawnPosition now resolves the place spawn point through the same path player spawn uses (resolvePlayerSpawnPosition, extracted from configurePlayerEntity in tome/lifecycle.ts), so the god entity — and the creator's camera — start where the player would spawn instead of at a marker cube at 0,0,0.

  • The applySpec player reconciliation is now god-mode-aware: re-applying a changed player appearance had rewritten/removed DrawVisibility and DrawInterpolation on the creator's player, popping the hidden body back into the world mid-session. The reconciliation now folds the spec's own visibility/interpolation into the TomeGodMode exit-restore slots and reasserts the session overrides (hide + interpolation-disable), so leaving god mode lands on the NEW spec appearance. The fold only runs when the update actually carries player appearance props — an update that drops them never touched the overrides, and folding there would capture the session's own hide as the restore value, leaving the creator invisible after exit. Runs identically on server and client (applySpec is shared), so no replication divergence.

  • applyMenuPick stamps god:menu-pick entries with the input frame's tick when the payload carries no capturedAtTick (the DOM drawer never sends one). The previous 0 default made expireStaleMenuPick (added in the object-qualified-picks commit) reap every pick the same tick it landed once the session was past the 2-second TTL — every drawer option (brush Radius/Strength/Mode, object Tint/Glow/Material) was a dead click. Same pattern applyToolbarPick already documents; the frame tick is identical on both sides, so the TTL now measures real age.

  • GPU fx populations without an authored maxParticles no longer get the CPU-era flat 3000 clamp (DEFAULT_MAX_PARTICLES in fx-gpu/backend.ts). They now get an arena-aware default: a 1/16 share of the shared particle arena (65,536 desktop / 16,384 mobile), bounded to a quarter of the arena's remaining free slots so defaulted populations degrade geometrically under pressure instead of hitting an exhaustion cliff, with the old 3000 as the floor. Oversized segments are near-free on this path — the fixed passes dispatch at arena capacity and threads early-out past each segment's live range — so the real ceilings stay overdraw and the 256-population cap.

  • A defaulted population's GPU-side spawn cap (population-table MaxParticles) and persistent-count clamp now equal its segment capacity; only an authored maxParticles soft-clamps below the allocation. Authored values are honored as written, subject only to the existing arena-tight halving clamp every request gets.

  • FxGpuArena gains freeParticleCapacity() (segment allocator free total) to size the default.

  • The CPU/ribbon backend keeps its modest flat 3000 default (cpu-backend.ts) — every alive particle costs CPU time there.

  • isComplexMixer (the horde-batch admission/demotion gate in renderer/three/models.ts) now treats mixer channels below a contribution epsilon (MIXER_CONTRIBUTION_EPSILON = 0.01) as inactive — mask and all. Previously any channel with weight > 0 counted as active, so the near-zero residual weights that locomotion blends (smoothstep/EMA weight ramps) settle into — idle: 0.001 next to walk: 0.999 — read as "two weighted channels" and permanently demoted every crowd member to the per-character clone path. A 190-enemy horde rendered as 190 skinned clones instead of one instanced batch. Channels at or above the epsilon behave exactly as before: a second contributing channel or a contributing masked channel still demotes.

  • Foot-grounding plant release is now plant-aware (foot-grounding.ts). A STRETCH release — the anchor left leg reach because the BODY moved (a script-commanded speed/gait snap like sprint 6→12 m/s in one tick), not because the clip swung the foot — hands the foot back to the clip: the release fade targets the ANIMATED foot (riding the body) instead of pinning the dead world anchor. Previously the IK target held the world-fixed anchor for the whole ~0.13s weight fade (FOOT_WEIGHT_RAMP_PER_SECOND 8, unchanged) while the body receded up to ~1.5m, so solveTwoBone parked the leg at its soft reach cap — a full-extension leg snap on every sprint press AND release. SWING release (toe-off) deliberately keeps the pinned anchor: hold-the-ground continuity through the fade is what makes toe-off look planted. The asymmetry is the fix and is latched per release (FootState.releaseToAnimated); a swing release upgrades to a stretch mid-fade if the body outruns the anchor during the ramp (anchorBeyondReach, the old stretch math split out of shouldReleaseLock). Since the solve toward the animated ankle is an identity over the clip pose, the residual fade is invisible — no pop at hand-back.

  • Pole direction is rate-limited (solver.ts): the remembered knee/elbow plane turns toward the fresh pose-derived plane at most POLE_MAX_TURN_PER_SECOND = 2π rad/s (~360°/s; visual constant — the knee plane sweeps over ~2–8 frames instead of snapping). This extends the window-1 hysteresis, which only protects near-straight limbs (POLE_TRUST_MAX_SIN 0.2 ≈ 11.5° of bend): a phase-unsynced gait crossfade produces genuinely-bent intermediate poses — full trust — whose bend plane tips laterally within one frame and whipped the knee sideways (the consistent "leftward" component: the tip's sign is fixed by the avatar's clip pair). Both the remembered and candidate poles lie in the plane ⊥ the target direction, so the clamp is an exact rotation about the target axis (Rodrigues). solveTwoBone gains a dt parameter (frame seconds) to feed the limit; both callers in applyIKPass pass the pass dt. Genuine clip re-aims (typically well under 90°) settle in under 0.25s; the worst-case caveat is a fast character spin (>360°/s yaw) briefly lagging the knee plane, bounded and continuous.

  • Tests (fail-on-parent): stretch-release fade tracks the animated foot with the commanded target never beyond 0.97× leg reach (vs parent pinning at the anchor and engaging the reach cap); a stretch-initiated release (slow foot, body accelerating away) follows the animated foot from its first frame; a swing release with the body planted keeps the anchor through the whole fade (the asymmetry guard); a synthetic one-frame 90° bend-plane tip moves the pole ≤ 2π·dt and keeps the knee on the remembered side, then settles onto the new plane over held frames. The window-1 noise-filter foot-grounding suite stays green; the existing "re-aimed bend plane" solver test now documents rate-limited follow.

  • Lag compensation now rewinds to the pose the shooter was actually shown. The server rewound to the newest applied tick (integer), but the renderer displayed an EMA chase trailing that tick by ~0.5–1.5 ticks — so the rewound capsule sat ~1 tick ahead of the displayed silhouette along its motion (trailing-edge clips missed, slight leads hit). Three coupled changes close the gap end to end:

    • Presentation is now reconstructible. The renderer's per-entity EMA chase (synthetic-transform-delta) is replaced by fixed-delay two-snapshot interpolation on the authoritative tick timeline: poses are buffered per delta tick and the displayed pose is pose(newestTick − 1 + alpha) (alpha = elapsed/tickInterval, clamped), blended with the exact same lerp/shortest-arc-nlerp the lag-compensation history uses. Teleport-threshold snapping is preserved. The arrival-cadence regime smoothing (intervalStep) is gone from entity transforms — bunched tick arrivals now keep their intermediate snapshots and replay on the timeline instead of being smeared.
    • The stamp says what was displayed. Input frames stamp remoteViewTick = newestAppliedTick − 1 + alpha (fractional; computeRemoteViewTick), with the ack tracker recording the arrival time of the newest acked tick as the clock base. Falls back to the integer newest-applied tick when no arrival time is known.
    • The server honors the fraction. clampRewindTick no longer truncates — fractional rewind ticks clamp in float space and history.sampleAt blends the bracketing records (it already could; it just never received a fraction). Applies to raycasts, sphere sweeps, and the instigated-sensor overlap path alike.
  • Residual display-vs-rewind error is now ≤1 render frame of sim→renderer pacing (~16 ms) plus flick-window camera smoothing, down from ~1 tick of target motion (0.1–0.27 m at 3–8 m/s, worse under degraded cadence).

  • Look pass rebuilds on script source edits (look-pass.ts): lookPassTopologyKey now folds the script's source hash into the topology key (script:${ref}@${hash}). An in-place edit (same ref, new source) is a topology change → present() rebuilds the compiled graph and re-arms pipelineFailed. Previously the key was hash-blind, so the pass presented the first compiled version of a look forever — every subsequent str_replace_editor edit compiled into the cache and was thrown away (debug-day 5.0 P1, app 6bf82e5c). Param tweaks (vocab uniforms and lookScript.params) keep the no-rebuild uniform fast path.

  • Full-reset applies clear session atmosphere overrides (interpreter.ts, room-runtime.ts, reset.ts): new clearAtmosphereSessionOverrides(world) drops the patchAtmosphere override layer — legacy + per-place resources AND the replicated TomeAtmosphereOverrides/TomePlaceAtmosphereOverrides components (cycle anchors ride this layer and reset with it). applyTomeSpec calls it when replace: true (the revert_to_version path), and resetTomeWorld now uses it too (previously it only cleared the legacy layer, leaking per-place overrides). Previously applySpec re-merged the kept override layer over every new spec, so a revert left the whole session's overrides (timeOfDay, sun, sky hybrids, look) alive forever — surviving page reloads by construction because they live in the room's ECS.

  • Explicit null scrubs nested atmosphere keys (atmosphere-utils.ts): mergeAtmosphere / mergeAtmosphereOverrides now recurse into nested plain objects, so patchAtmosphere({ sky: { turbidity: null } }) deletes the key from the effective atmosphere (tombstone kept in the override layer, scrubbed in the merge). Previously nested nulls survived the shallow spread as literal null values — nothing nested could ever be removed, which is what trapped the 5.0 hybrid-sky residue.

  • Silent render failures reach Savi (renderer-backend.ts, look-pass.ts, engine-diagnostics.ts): backend GPU onError (pipeline/validation/OOM) now routes through reportEngineDiagnostic as renderer-gpu-error (deduped per distinct api+type+message; console.warn keeps firing per occurrence), and the look pass's conservative pipelineFailed drop reports look-pipeline-failed naming the look ref (once per flip — the flag gates storms). Both codes added to the server allowlist → getLogs + one DM. Previously both paths were console-only, which is why a session with a visibly broken look had clean getLogs.

  • Dynamic-spawn behaviors now compile on demand from the replicated ref (src/tome/compiled-behavior.ts, debug day 5.0 #78). Dynamic string-spawns (objectApi.spawn with a behavior field) only compiled on the side that executed the spawn call; an entity materialized through replication ingest (join snapshot, AOI stream-in, predicted spawn racing its confirmation) carried TomeBehaviorRef.behaviorRefs but no compiled.objects entry, so its hooks silently never ran on that side. Because TomeSpawnedBy is replicated and player entity ids are session-stable, such entities still classified client-predicted — compared every tick but never simulated. The golf game's hole-arrow (t += dt bob/spin) showed the signature: a constant Δp50 = 2-tick state.t mismatch at 99.3% present rate that 17 corrections/s re-seeded but could never converge, burning 408 ms/s of resim forever. resolveCompiledBehavior lazily compiles from the spec scripts (negative-cached per CompiledSpec; onSpawn deliberately not re-run — state arrives via replication) and is now the single lookup used by behavior-update, input-applier, interaction-dispatch, NPC noise listeners, object-api, and the god-mode editor passes.

  • Per-tick prediction delta capture is never entity-filtered (recordPredictionHistory in projection-history.ts, debug day 5.0 #79). The client oplog's deltas were scoped to the prediction envelope, but predicted behaviors write outside their envelope — the golf cannonball script-detects hits and patches static targets' broken state. Those rows never reached the oplog, so buildRollbackScope never restored them and every resimulation replayed against a future-contaminated world: the replayed ball skipped targets its abandoned timeline had already broken (if (t.state.broken) continue), missing hits the server made — server-only _hitLog entries, forked kill/score chains, diverged id-seq spawn counts (8 vs 4) and owned-entities childIds, camera warps. Full snapshots stay envelope-filtered (memory), and the mismatch compare filter is untouched (no new compare noise, AOI-scatter hazard still solved). The capture policy lives in one shared helper used by the client runtime and the resim replay loop.

  • Tests, both verified fail-on-parent by reverting each fix in isolation: join-offset-behavior-state.test.ts (two-world join fixture; control spec-object scenario isolates the lazy compile) and predicted-cross-write-rollback.test.ts (scripted ball + smashable target + one rollback forced across the hit tick ⇒ identical hit chains, target state, FX spawn counts both sides).

  • River marks now resolve their authored points into a centripetal Catmull-Rom spline centerline (sampleCentripetalSpline in program/path.ts, resolved once per definition revision in resolveTerrainMarkEntries). Centripetal parameterization (alpha 0.5) interpolates exactly through every authored point and cannot cusp or self-intersect between them; sampling is curvature-adaptive (3 m straights → 0.75 m in tight bends, 1024-point budget per river with graceful spacing degradation). Every consumer measures against the same centerline: the height carve, bank/bed material blends, mark bounds, scatter exclusion (tome/scatter.ts, with a bounds pre-reject so the dense polyline never runs for far samples), liquid queries, and the water-surface geometry (marks-liquid.ts rides the centerline instead of re-resampling the raw polyline). The carve's water-profile arc-length projection already ran against the dense profile polyline, so per-sample cost shape is unchanged.

  • Ocean carve rewritten from "push already-underwater terrain down by a depth-keyed offset" (which produced walls at the mark boundary, no shore, and 1 m-deep "oceans" over low terrain) to a coastline profile keyed on distance from the authored boundary: a slope-limited beach band outside (terrain eases to a sand berm 0.35 m above the waterline, band widens up to 48 m on tall coasts so bluffs ease down instead of cliffing), a wadeable shelf (2.2 m over 14 m), then a smootherstep drop at a ~35° continental slope to the full authored depth, floor relief preserved at 50% of the base terrain's below-sea variation. The drop width clamps to the basin's interior reach so small bays still hit their authored depth. Terrain rising ≥14 m above the water past the coast band survives as islands; the island gate is keyed to profile progress so the blend's derivative vanishes exactly where the profile's does (monotonic shore→depth, no ripple at band junctions). Raising is gated on water-mark overlap so river mouths are never dammed; ocean influence reach shrank from 250 m to the 48 m beach band (bounds tighten accordingly).

  • floorMaterial now paints a sand apron across the beach band outside the waterline (18 m reach, fading out by 5 m above the water) instead of cutting off hard at the boundary/water level — island rims get sandy rings for free.

  • All mark height/material functions remain pure and deterministic (no RNG, no clock); server and client sample identically, pinned by a new determinism test across both resolve regimes. Heightmap build golden fixtures re-captured for the two ocean-mark cases (intended output change); all other fixtures byte-identical.

  • Made the room.full re-pick loop unconditional on the client (bug: https://github.com/earth-kiln/main/pull/6677#issuecomment-4627648366). The retry handler used to exist only when the pre-join routing context resolved (index.ts wired onRoomFull solely off resolveRoutedJoinSetup); the server enforces door admission whenever the live spec has spec.routing, so any client whose routing-context fetch failed or was answered "routing disabled" (stale kiln spec cache, 401, RPC race, legacy SDK host) connected with no retry handler, got rejected at the cap, and dead-ended on "This room is full. Reload to try another room." — on every reload, since the boot condition repeats. New createRoomFullRetryHandler (room-routing-bootstrap.ts) is wired into every connectRoom: it keeps the compiled pickRoom hook driving re-picks when the routed join exists, and otherwise lazily builds a fallback RoutedJoin from the rejection's own rooms snapshot with the builtin firstOpenRoom picker (strictly-under-cap open predicate, rejected-room exclusion, fresh nextRoomName mint).

  • Bounded the loop: MAX_TOTAL_ROOM_FULL_REJECTIONS = 5 rejections per page load (the hook is consulted for the first 3, then fresh-name rollovers); the reload copy is now reserved for genuine failure — budget exhausted or a connection-URL fetch error.

  • Verified-correct pieces left untouched: firstOpenRoom's open predicate (playerCount < maxPlayers), the fresh-room spill, applyAdmissionRejection, server-side admission + room.full snapshot, and patchRouting.

  • Tests: createRoomFullRetryHandler suite in room-routing-bootstrap.test.ts (no-pre-join-context spill to room-2, empty-snapshot mint, Tucker's verbatim routing.js with a stale 1/2 count, consecutive-rejection exclusion, retry-budget exhaustion copy, connection-URL failure) plus explicit maxPlayers-2 second/third-player cases in room-routing.test.ts.

  • run_script now exposes getLogs on api as well as the bare global (script-dispatch.ts) — both spellings are the same function. Savi's property-enumeration of api previously found no log channel and concluded none existed (debug-day 5.0 Painterly Vale session).

  • custom-materials skill body now carries the water redirect that previously lived only in its frontmatter description: water waves = liquid.waves/turbulence/crestFoam on the mark, or material: { kind: "water" } — instead of overlaying a scripted displacement plane on builtin water.

  • custom-materials skill documents the vertex-displacement contract: positionNode does not recompute normals, and the plane primitive bakes its flat orientation into geometry (positionLocal.y is 0), so displacement should be driven from positionLocal.x/.z and paired with a normalNode when the motion should shade.

  • voxel-terrain skill gains an Interactive Editing example, "Highlight Your Cursor": raycastVoxel returns integer voxel cells (not object ids), so setVoxelMark(id, hit.voxel, opts) / setVoxelMark(id, null) is the block-world counterpart to highlight(objectId).

  • Lighting prompt example (_examples/behaviors.ts → tome-api-prompt) now names the containment consequence of shadows: a shadowless light passes through walls and tints geometry outside; interior lights want shadow: { enabled: true }.

  • applySpawnProperties (the api.spawn dynamic-spawn path in src/tome/api/object-api.ts) now applies castShadow/receiveShadow via the existing propertySetters. The per-object shadow surface was already end-to-end everywhere else — ObjectProperties type, ObjectPropertiesSchema Zod, interpreter applyAppearanceProps (spec objects), setProperty/setObjectProperty, DrawShadow ECS component (aoi-replicated, renderer-forwarded), renderer shadow handler + standalone/lane meshes — but runtime spawns silently dropped the flags, so a behavior-script api.spawn({ properties: { castShadow: false } }) kept casting while every readback claimed it didn't (Tucker's geometry lightning bolts, debug-day 5.0).

  • Defaults unchanged: flags omitted → no DrawShadow component → cast and receive both on, and flipping both flags back to default still drops the component.

  • Tests: spawn-path cast/receive application + readback, default-unchanged, live flip via setObjectProperty (object-api.test.ts); strict Zod round-trip (schema-docs.test.ts). Renderer-level mesh.castShadow coverage already existed (engine/renderer/__tests__/primitive.test.ts).

  • requestSpecUpdate (object-api) now coalesces with any spec update already pending in TomeSpecUpdateResource instead of clobbering it: the EARLIEST pending baselineSpec is preserved. Previously a multi-mutation burst (one run_script doing patchPlayer + removeBehavior + addBehavior + camera swaps) left a pending {spec: S_N, baselineSpec: S_(N-1)}, so specUpdateSystem diffed only the final mutation. Live-entity reconciliation gates in applySpec (player appearance, physics, behavior onSpawn) saw no change for every earlier mutation — runtime Draw state from before the burst (e.g. the old avatar's DrawModel) survived on the server until the player refreshed, while clients diffed the full spec against their own baseline. This is not avatar-specific: any same-tick patch + behavior-swap burst lost live reconciliation for all but the final delta.

  • When the pending update carries no baseline but the new request does, the new baseline is adopted (pending?.baselineSpec ?? baselineSpec) — covers a queued re-apply (e.g. placeResident) followed by an object-api mutation that already advanced GameSpecResource.

  • Regression tests in spec-update-coalesce.test.ts: baseline coalescing unit tests plus a live-player burst test (avatar model → primitive swap + behavior swap in one tick) asserting the player ends with DrawPrimitive and no DrawModel after specUpdateSystem.

  • Sun cascade shadows now install the fork's radius-aware Vogel-disk PCF (PCFShadowFilter: 5 IGN-rotated taps × hardware 4-tap compare) as each cascade's filterNode. The renderer renders with PCFSoftShadowMap, whose filter is a fixed 3×3 kernel that ignores shadow.radius entirely — the per-cascade PCF radius SunCascadeShadow.fitCascadeDepthRange computes (one world-space penumbra target over the cascade's texel size) had no GPU consumer and governed nothing on screen. With the override, radius is read per render as a uniform and the penumbra targeting is real. Local-light shadows (shadow atlas) stay on the renderer default.

  • Radius bounds re-sized for the now-live consumer: cap 2.5 → 3.5 texels (high tier's near cascade, ~0.05 m texels, needs ~3 texels for the 0.15 m target penumbra — the old cap would truncate it), floor 0.4 → 1.0 texels (Vogel support ≈ 2·radius+1 texels; 1.0 keeps coarse/far cascades at least as soft as the 3×3 PCFSoft kernel they previously rendered with, so the filter swap can't sharpen the far-cascade striping genre).

  • This is aimed at the grazing-sun diagonal-band report (cascade texel-grid aliasing): the per-pixel IGN rotation dithers the grid pattern into noise instead of bands. The bug itself stays open until the repro is re-tasted on stage — the softness/dither tradeoff at grazing angles is taste-gated.

  • Root cause of the default wet sheen: the Patina NRO roughness channel is ML-derived from the albedo alone (fal-ai/patina) and trends glossy for ground textures, sitting at or below TERRAIN_ROUGHNESS_FLOOR (a min-clamp wet-plastic guard, 0.55) over wide areas — so the floor became the effective roughness and terrain rendered a flat 0.55 semi-gloss, which reads shiny/wet at grazing sun angles. A min-clamp can only permit gloss, never remove it, so the floor itself was not the de-shine lever.

  • New per-material roughnessIntensity knob (0-2) on heightmap terrain materials, plumbed exactly like normalStrength: spec type + Zod schema → interpreter signature/config → MaterialDef → buildMaterialPack colorBy → readTextureColorBy (clamped 0-2, in the layering signature) → render config component → layer atlas params. The atlas params texture grew a PBR2 row (TERRAIN_LAYER_PARAM_ROWS 13 → 14, [roughnessIntensity, unused, unused, unused]), fetched only on tiers that sample the NRO array. The shader multiplies the NRO roughness sample (top and biplanar side projections) by the intensity and saturates — rescaling preserves the roughness map's spatial structure instead of flattening it to a scalar.

  • The actual default de-shine: buildMaterialPack defaults Patina-derived NRO layers to roughnessIntensity 1.4 (PATINA_DEFAULT_ROUGHNESS_INTENSITY) when the spec doesn't set one — existing games read matte with no spec changes. With the ×1.4 lift, blended samples ≥ ~0.39 land at or above the old 0.55 guard.

  • TERRAIN_ROUGHNESS_FLOOR lowered 0.55 → 0.4, justified only by the default bump above: in the default state the bump keeps typical blended roughness past the old guard, and the lower floor exists as headroom so an explicit roughnessIntensity < 1 ("polish") can actually reach below 0.55. Stated tradeoff: derived samples whose blended value lands under ~0.39 can now render down to 0.4 instead of riding a flat 0.55 — localized glossy spots the derivation actually authored, while the terrain-wide average moves matte-ward.

  • Reaches Savi automatically through patchTerrain material helpers (strict schema now accepts the key); documented in the heightmap-terrain skill and the generated tome-api prompt.

  • TerrainChunkEdits now rides the prediction rollback envelope. The client oplog capture filter (isPredictionProjectionEntity, a superset of isClientPredictedEntity) includes terrain chunk entities, so buildRollbackScope picks up locally-applied voxel edits and applyAuthoritativeState restores server truth at the mismatch tick before resim replay. Replayed clicks raycast the tick-exact world and re-append the IDENTICAL edit (same editId/revision) — previously each resim pass re-read the abandoned timeline's block and extruded a fresh voxel toward the camera (one click → many voxels, plus the ghost flash when the authoritative row landed). Chunks stay direct-snap ingested and outside mismatch comparison; only edited chunks enter the oplog.

  • Mixed-ownership rollback semantics for chunk entities: client streaming tags chunks ClientEntity, which used to make the selective-rollback skip rule preserve predicted edits the server had never confirmed. Chunk entities now have their server-absent replicated components removed (entity + client-plane bookkeeping survive; never despawned/respawned), and their oplog base rows are cleared when server truth has nothing.

  • setTerrainChunkEditsState / clearTerrainChunkEditsState (and the replace path's removals) record recordPendingLocalEdit, so authoritative rows older than an in-flight local edit defer instead of snap-wiping it (same overlay the field store uses). Resimulation replays and server worlds no-op the record.

  • Material-lookup overlay: bulk edit transitions (rollback restore, authoritative replace, clear) backfill abandoned cells with the GENERATOR's truth instead of deleting their overrides — the stale impersonated build is the next read fallback and would otherwise keep answering the abandoned value until the convergence rebuild. Budgeted at 4096 cells per transition (larger region replaces keep the old delete-and-wait behavior).

  • TerrainChunkEdits gained a content-identity equals gate (revision + timestamp + editId sequence): identical-content restores/direct-snap rows (fresh clones from the oplog/wire) no longer count as changes that tear down the resident fast-lane grid and queue redundant rebuilds — the resim-storm churn amplifier.

  • water-material.ts: foam is now composited inside the lit PBR surface — colorNode = mix(waterColor, foamColor, foamVisible) plus the existing roughness lift — replacing the post-lighting material.outputNode mix toward raw foamColor. The outputNode path ran after setupLighting() and after fog (setupOutput), so foam was an unlit, unfogged constant (the "river disproportionately bright at sunset" and "objects behind water render fully white" debug-day reports).

  • Shore foam no longer saturates into a solid fill: shoreThreshold is floored at 0.18 so foamTexture always modulates the shape (a threshold ≤ 0 at full contact made shoreFoamShape ≡ 1), the contact fade is squared before the fill mask so contact foam concentrates at the waterline instead of covering everything shallower than contactFoamWidth (meters on gentle pond/river beds), and foamVisible ramps over 0.015→0.35 instead of binarizing at 0.12 so computed foam intensity survives to the blend.

  • Test harness: voxel-bucket-wgsl-test-utils.buildWgsl now wires backend.renderer (normally done by Backend.init) so viewport depth/shared-texture materials (water) can codegen headless; new water-material-wgsl-dominance.test.ts pins the water fragment/vertex WGSL against the select()/toVar branch-trap class.

  • Editing a behavior script re-ran onSpawn but never reaped timers armed by the previous script version. TomeTimerEntry callbacks are raw closures over the dead compiled module with no script identity, and the only reapers were owner-destroy (clearTimersForEntity, destroy cascade only) and owner-gone-at-dispatch — so Savi's self-rescheduling runInSeconds loop idiom became an immortal zombie chain on every edit, re-arming under fresh ids forever (debug-day dumps 8f33e24f/868a9cc9: three concurrent storm loops, tube-bolt lightning from code present in no spec script). The hot-reload boundaries already cleared event subscriptions (clearEntityEventSubscriptions, added for exactly this stacking class) but missed timers. clearTimersForEntity is now exported and called at both re-run sites — updateObject's behaviorChanged branch and rerunOnSpawn — right next to the event-subscription clear.

  • Hazard handled: owner-scoped reaping would also kill duration cleanups the ENGINE armed on the edited entity (effect/pushLook/highlight duration-clears, spawn lifetime destroys). Those four sites now arm through armEngineCleanupTimer, which tags the entry engineOwned at creation; script-edit reaping passes keepEngineOwned so a mid-duration edit can't strand a look layer, highlight, or immortal lifetime-spawn. Destroy still reaps everything. Long-term these cleanups should become expiry-as-data on their resources (the shape effect() already half-has with expiresAt) instead of timers.

  • New introspection: api.getTimers(ownerId?) lists active timers ({ id, ownerId, dueInTicks, dueInSeconds, engineOwned }, soonest first, world-wide by default) — Savi found this bug by pure inference because nothing could enumerate armed timers.

  • Tests (timer-zombie.test.ts): edit-while-armed kills the old chain on both hot-reload paths (spec object + dynamic spawn), revert/spec-replace and destroy reaping regression-guarded, engine-armed highlight cleanup survives an edit and still clears, getTimers shape.

  • Converging-write classification in the prediction comparator (mismatch-classifier.ts): every mismatch row is classified at compare time as push (server-only write the client structurally cannot predict — e.g. a server-realm game-manager patching a predicted player's tome/state; provenance = no client write to that component path in the buffered oplog window), skew (authoritative value equals the client's predicted value at T±k, k ≤ 3 — late-frame salvage fire shift), or drift (genuine determinism divergence). Classification is display + accounting only — zero behavior change to corrections/resim; the rollback path still applies authoritative state exactly as before, and it runs only on ticks that mismatched (already the slow path).

  • The headline mispredict rate now counts ONLY drift. [window-15s mispredict] reads drift=N/total(%) push=N skew=N decayed=N absent=N; the [mismatch] FOUND line carries class=, per-field rows carry [push]/[skew]/[drift] tags, and the [mismatch-rec] digest header splits mismatchedTicks by class with per-field class tags.

  • Guard rail (tested): a seeded determinism bug in a both-sides behavior still produces drift-classified rows and a nonzero headline rate; gm-pattern pushes and 1-tick salvage skews never page the headline.

  • api.raycast now registers NPC hurtboxes by default. The properties.npc default hurtbox is a kinematic trigger capsule (a Rapier/mantle sensor), and tome raycasts passed EXCLUDE_SENSORS to the engine unless includeSensors: true — so every taught bullet pattern (hitscan in combat.md, the raycast-stepped projectile in projectiles.md) sailed straight through NPCs and burst on the wall behind them. The engine cast now always includes sensors, and runRapierRaycast/runMantleRaycast post-filter: a sensor hit registers only when the entity carries NpcAgentCfg (an NPC hurtbox); every other sensor stays ray-invisible.

  • Plain trigger zones keep today's semantics with correct pass-through: a ray entering a non-NPC sensor doesn't stop there — the single-hit path falls back to the all-hits cast and returns the nearest visible hit behind it (zone in front of an NPC or wall resolves to the NPC/wall, not null).

  • includeSensors: true is unchanged as the raw escape hatch: no filtering, nearest sensor wins.

  • The filter reads only replicated components (PhysicsBodyConfig.sensor, NpcAgentCfg), so client and server raycasts stay deterministic; lag-compensated hit merging is unaffected (compensated player hits are solid and merge after the sensor filter). Engine-level castPhysicsRay/castMantleRay defaults are untouched — only the tome api.raycast path changed.

  • queryWorld picks tag-first candidate selection for selective tag queries with a radius (debug day #92, Tucker's elevator server profile: EntityTable.indexOf 19.9% self, queryWorld 25.5% total). Radius queries used to always walk the octree and tag-filter per candidate — query({ radius, tags: ["player"] }) (the findPlayer/inTalkWindow behavior-script staple, called per scripted entity per tick) paid 2 string-keyed world.gets for every in-radius entity to keep a handful of matches. When the tag index bounds the candidate set to ≤ ¼ of the place's octree population (cheap set-size peek, no materialization), the query now filters by tag first and radius-checks the few candidates. Dense-tag sweeps (e.g. hundreds of letter entities) keep the spatial path. Result-identical by the same invariants radius-undefined queries already rely on: the tag index is authoritative for tag membership, and the octree mirrors WorldFeetPosition synchronously (spatial hooks fire on write) so radius predicates agree; both paths id-sort. Elevator-shaped bench (1200 entities, 120 queries/tick mix): 104.0 → 48.2µs/query, 2.16x; selective player queries individually collapse ~50x.

  • SpatialOctree.queryRadiusNode iterates node entries with Map.forEach instead of for (const [k, v] of map) — the destructuring form allocated an iterator + tuple per entry per node per query (the 6.6%-self iterator next slice in the same profile, 976/1286 edges from queryRadiusNode). Same insertion order, same results. SpatialOctree.size + getSpatialPopulation() expose the indexed-entity count for the path choice.

  • matchesAllTags skips the every() closure for the dominant single-required-tag case.

  • New pins in query-fast-path.test.ts: randomized brute-force-reference equivalence across both paths (tags/anyTags/multi-tag/untagged/no-radius, with entities moving between rounds), stale-octree-candidate semantics preserved exactly, and a perf-shape pin (selective tag query in a 400-entity world does < 20 component gets; the spatial path's ~800 fails on the previous code). scripts/bench-query-hot-path.ts reproduces the profile shape on demand.

  • Measured and rejected: per-candidate resolveEntityIndex + getByIndex in the query loops benched 0.75–0.93x (the saved string Map.get ≈ the added call overhead once string hashes are cached) — the win is cutting candidates, not cheapening per-candidate reads. Server CPU wins from the 101-players-in-one-AOI profile (42.84s bun CPU profile, server ~94% busy — https://github.com/earth-kiln/main/pull/6677#issuecomment-4627421301). All four changes are behavior-identical and pinned by fail-on-parent tests; combined ~10–13% of server CPU at that load.

  • Terrain desired-chunk dedupe (terrain/streaming.ts, ~6% of profile): computeDesiredChunks walked the full LOD spiral once per player — 101 co-located players did 101× identical work, and every duplicate center's candidates lose all upsert tie-breaks anyway (same lod/distance/priority, strictly higher order). Player chunk coords are now deduped (first occurrence preserved) before the spiral walk; the chunk budget still scales with the real player count. The stationary-streaming cache key for non-voxel generators is now the ordered-unique occupied cells + player count instead of the per-player position list, so co-located movement inside the same cells (and pure vertical movement — surface coords resolve to [cx, 0, cz]) no longer forces a full recompute. Voxel keeps the per-position key (its Y resolution carries per-player hysteresis state).

  • PacketWriter capacity cache (room-wire-codec.ts, ~2.4% profile self in the ArrayBuffer.byteLength getter): every wire write funnels through ensure(), which read this.buffer.byteLength (a native getter) per write. Capacity is now a cached number field. Bytes on the wire are unchanged.

  • registry.list() memoization (ecs/registry.ts, ~1.9%): replication entity classification (entityHadVisibleComponentsAtTick / entityHasVisibleComponentsNow) walks the component list per entity per drain, and list() ran Array.from per call. Now memoized and invalidated on register(), matching sortedByIdCache.

  • Object-map patch journal single-entry fast path (replication/object-map-patches.ts, ~1.5%): mergedPatchFor cloned the journal patch once per (component, entity, receiver-class) even when the drain window held exactly one entry — the steady-state case. Journal entries are immutable after record() and all consumers (the wire writers) only read, so the single-entry window now returns the stored patch directly; multi-entry windows still merge onto a fresh clone.

  • Publish pipeline: v4/skills/<semver>/skills.json now mirrors the SHA-keyed bundle the catalog names for that semver (ETag compare, refresh on mismatch) instead of being write-once. Same-semver re-publishes (catalog SHA bumps) now reach Savi's per-version instructions; previously 5.0.0 kept its pre-rooms draft skills forever.

  • buildScriptedMaterial now runs a build-time finiteness walk over the returned material's node slots (scripted-material.ts): a NaN/Infinity baked into a const or uniform — the classic cause is JS arithmetic on a TSL node (scale * 2.3 instead of .mul(2.3)), which coerces the node to NaN and compiles into a WGSL literal the GPU rejects — reports one scripted-material-runtime-error diagnostic naming the slot with a teaching message and returns null, so the entity takes the existing Std/PBR fallback instead of shipping an invalid pipeline.

  • Scripted materials are now named Material:Scripted(<ref>) (material-key.ts). three labels GPU pipelines and shader modules with the material name, and WebGPU device errors quote those labels — so device-timeline failures carry the script ref.

  • createRendererBackend gained an onDeviceError hook on its initialize options; the render worker (renderer.ts) uses it to extract a scripted-material ref from uncaptured GPU errors (invalid pipeline at Queue.Submit, async WGSL parse failures) and route it through the existing build-failure path: park to Std/PBR, one attributed diagnostic, auto-unpark on the next script edit. Previously these errors were a bare per-submit console.warn that never reached Savi.

  • One skill line in custom-materials.md: ctx.param() returns a node — scale it with .mul(2.3), never JS *.

  • New src/tome/server-behind-monitor.ts: the room runtime feeds each simulation pump frame's measured tick work (getServerRuntimeTelemetry → lastTickWorkMs / lastSteps) into observeServerTickHealth. The gate is wall-clock coverage — when ticks over budget×1.5 have covered ≥80% of the trailing 5s window's wall time (and at least 4 over ticks, so one anomalous monster tick can't impersonate an episode), the monitor enters a "behind" episode: one warn entry in the runtime log (getLogs(), code server-behind) and one DM to Savi over the same deduped rail ObjectAPI's notifyDmOnce uses (TomeDmNotifierResource + TomeDmNotifiedKeysResource, key server-behind#<episode>). Coverage rather than frame counting because the production pump is a blocking setInterval whose frames coalesce under exactly this load (8-step frames every 8×tickMs) — the detector stays reachable at any pump cadence and tick rate. The message carries the measured slow-tick ms, the budget, and the tick rate. The episode ends only when the window recovers (over-work coverage ≤25%), and DMs are additionally spaced by a 60s re-arm cooldown — a flapping server can never mint more than one hidden Savi turn per minute.

  • input-config.ts resolveKeyBinding now resolves every KeyboardEvent.code in the engine Key constant (Enter, Escape, Backspace, F1-F12, punctuation, Meta, nav keys — ~35 codes that KeyCodeSchema blessed but the resolver silently dropped). Tab stays unbindable (reserved by the Spawn chrome for the overlay toggle).

  • The action keys path now normalizes tokens the same way the axis path always did — interact: { keys: ["Enter"] } (the taught example in types.ts) previously produced ZERO bindings while validating clean and persisting; the key was dead at runtime with no diagnostic. normalizeToken is collapsed into resolveKeyBinding, so actions, axes, and modifier keys all resolve identically; case-insensitive shortcuts ("ENTER", "Shift") now resolve too.

  • Code-shaped tokens outside the Key constant (e.g. NumpadComma) still pass through verbatim — raw capture records ev.code strings the constant doesn't enumerate.

  • input-config.test.ts iterates every KeyCodeSchema member through both the actions and axis paths asserting a binding is produced, so the schema and resolver can never diverge again.

  • Interpolation now speaks one authored shape: { teleportThreshold } | null (the spec/schema/prompt contract). writeDrawInterpolation normalizes any authored value into the kind-discriminated DrawInterpolation component, so the component invariantly stores the internal form and readers no longer depend on the tolerant dual-shape parser for new writes. Engine call sites passing the internal { kind } shape keep working.

  • getProperty('interpolation') echoes the authored shape back ({ teleportThreshold } when enabled, null when disabled) instead of leaking the internal component — spawn(... getProperty echo ...) used to fail PROPERTY_VALIDATORS.interpolation on the round trip.

  • Fixes a latent crash: setProperty('interpolation', null) (the documented disable) wrote null into the component verbatim; a second write then read .kind off null and threw. null now normalizes to { kind: "disabled" }.

  • The zoo's interpolation exhibits (the only authored content writing the internal { kind } shape) are migrated to the taught shape, clearing the third of three permanent validate_spec errors on the zoo.

  • VoxelMaterialDefSchema learns tags (mirrors VoxelMaterialDef.tags in types.ts). The strict patchTerrain validation no longer rejects the taught ladder/climbable pattern (addMaterials: { oak_ladder: { tags: ['climbable'] } }) with unrecognized_keys — previously the material was silently dropped and queryVoxels({ tag }) found nothing forever.

  • ParticlesSpecSchema.blend enum gains "subtract", matching the engine's particle BlendMode vocabulary (alpha/add/multiply/subtract, screen = legacy alias for add). Subtract-blend particles rendered live but were rejected by the kiln in-area persistence gate and erred in validate_spec.

  • LayoutSpecSchema rewritten from the pre-4.3 legacy shape (required maxExtents, x/z only, removed fit still described) to the engine's LayoutSpec: minExtents/maxExtents both optional, all three axes. The taught layout: { minExtents: { y: 2 } } no longer fails persistence with "maxExtents Required". Legacy fit specs still parse (non-strict) and the engine still honors them at runtime.

  • Wall-hole schema drops the bottomY-required refine: the engine defaults the sill to 0 (bottomY ?? y ?? 0 in renderer geometry and primitive colliders), so holes: [{ x, w, h }] is valid authored form.

  • SpriteSpecSchema learns the 10 engine sprite fields it omitted: playing, speed, loop, time, pixelsPerUnit, anchor, tint, opacity, blend (5-value SpriteBlendMode), layerMask. These now appear in Savi's generated <object-properties> reference and survive schema round-trips instead of being stripped.

  • New parity suite spec-schema-engine-parity.test.ts validates the zoo spec and DEFAULT_GAME_SPEC against GameSpecSchema so authored engine content can never drift schema-invalid again, plus per-surface regression tests for each gap above.

  • Third-person camera occlusion gains an asymmetric envelope (stepOcclusionEnvelope in tome/systems/camera-behavior.ts): pull-in stays tick-exact (the camera never looks through or sits inside a wall), pull-out now waits for 4 consecutive clear ticks (~130ms) and then eases back out at 2/s — matching the built-in spring arm's pushOut taste. Previously the sphere-cast clamp was applied raw every tick in BOTH directions, so any occlusion hit (walls, props, terrain crests at grazing angles) warped the camera in and instantly back out, oscillating at tick rate. While no cap is active the cast value passes through untouched, so zoom and spring-arm feel are unchanged; behavior-authored state.collisionDist still bypasses the envelope entirely.

  • The renderer's first-person collapse is now a blend, not a threshold (camera-smoother.ts): the rotation source crossfades from the orbit lookAt to the raw pointer orientation via a smoothstep of orbit distance across [0.35, 0.65] with a 50ms time ease. The old hard switch at dist > 0.5 sat exactly on the occlusion clamp floor (0.5), so a hovering hit distance flipped the rotation source every frame — a ~1.2 rad snap whenever lookOffsetY != heightOffset (every custom orbit camera).

  • Tests: camera-archetypes.test.ts drives a real Rapier wall through an occlusion sequence (tick-exact pull-in, no instant restore on a single clear tick, pinned under tick-alternating occlusion, monotonic eased recovery, tick-exact re-occlusion); camera-smoother.test.ts bounds the per-frame rotation step across a collapse to the 0.5m floor and under a hit distance hovering at the old threshold. All three fail on the parent commit.

  • FX particle decks no longer participate in scene fog (debug day 5.0 #26, Savi's storm-cloud report). applySpriteBlendMode enabled fog for "alpha" content only, so past the fog falloff the alpha deck's fragment mixed the per-particle tint into the haze color — the population's color binding read as "ignored" (near-white/pale-blue puffs no matter what was bound, even full red) while the SAME population's additive decks stayed vivid because add/subtract/multiply never fogged. The tint itself was never dropped: the binding rides the attribute/storage lane and applySaturationWeightedRecolorTint compiles correctly on every deck (verified end-to-end through the real CPU backend, the FxVM encoder/arena upload, and the backend's real WGSLNodeBuilder — the select/toVar miscompile suspected during debug-day triage is NOT present in this path).

  • Mechanism: applySpriteBlendMode(material, blend, options?) gains a fog opt-out (sprite-blend-mode.ts). Both fx deck creators pass { fog: false } — the CPU batches (renderer/three/particles.ts createBatch, which also covers trail batches) and the GPU batches (renderer/three/fx-gpu/render.ts createBatch). Sprite SURFACES (single sprites, sprite batches, cutout foliage) keep the existing vocabulary: alpha surfaces still fade into the haze like the meshes around them.

  • Premultiplied compositing is untouched: alpha decks still output rgb·a after the tint (One/OneMinusSrcAlpha factors), so there is no fringing change against bloom. Zero-recompile law upheld — fog is set once at deck-material creation, and alpha decks no longer pick up a pipeline-cache-key dependency on scene fog presence (toggling scene fog used to rebuild their pipelines).

  • Regression detector: renderer/__tests__/fx-tint-alpha-deck-wgsl.test.ts transpiles the real CPU and GPU deck materials through the backend's WGSLNodeBuilder with a production-parity builder.fogNode wired, pins tint-feeds-output + premultiply-present + no-fog-stage, runs the WGSL dominance analyzer over both stages (the r0.184 select/toVar hazard detector), and drives the real CPU fx pipeline to assert the bound color reaches the per-instance attribute.

  • Taste tradeoff, named: distant alpha-blend fx (e.g. ground smoke far away) no longer fade into the haze. They already sat next to additive embers that never fogged, so effect-internal coherence wins; if per-effect fog participation is ever wanted, the right shape is a per-sink knob, not a blend-keyed default.

  • Parented assemblies no longer tear apart in the drawn frame (DD5 P1, dump 53efcbae — a parented car's parts "slowly come apart then snap back"). Root cause: the renderer's presentation smoothing (synthetic-transform-delta) ran an independent timeline per entity — own snapshot ring, own arrival clock, own teleport threshold. A driven vehicle carries the control-target DrawInterpolation (teleportThreshold 20) while its runtime-spawned children keep the implicit default (threshold 2), so any burst-delivery correction ≥ 2 m (4g stalls at race speed) snapped every child to the corrected pose while the parent slewed on its own clock — the assembly visibly came apart on every correction and reconverged, repeatedly. Reproduced end-to-end (real server netcode + client runtime + prediction + render channel + smoothing) at 0.694 m of drawn separation.

  • Fix: hierarchy-consistent interpolation. tome/parent now rides the render channel (forwardToRenderer: "always", registered in createRendererRegistry), and parented records store their snapshot ring parent-relative, composing every displayed frame against the parent's displayed pose (child = parentDisplayed ⊗ relDisplayed). The child rides the parent's drawn timeline — corrections, folds, and teleport decisions included — while its own clock only animates parent-relative articulation (wheel spin, wing pitch). Multi-level chains compose in depth order; bone-attached children keep their world records (the renderer attachment system owns them); broken chains (parent despawn, spawn races) demote to world space and self-heal back to rel when the parent's timeline resolves.

  • Lag-compensation note: root entities (remote players — the actual rewind targets) are byte-identical to before; parented children now display on the parent's clock, so a child's drawn pose is no longer guaranteed to equal its own sampleAt(remoteViewTick) reconstruction (it equals parent.sampleAt ⊗ rel instead — rigid-correct).

  • New fail-on-parent suite engine/runtime/__tests__/hierarchy-drawn-pose.test.ts: full-stack harness (real server netcode + input acks + client prediction/resim + ecs-sync + render-channel + smoothing) asserting the child's displayed world pose stays rigid to the drawn parent across interpolated frames AND through per-tick corrections under 4g stall/burst delivery. The driving case fails on the parent commit at 0.694 m; anti-vacuity assertions pin that corrections actually flowed (acks > 10, displayed parent tracked server motion).

  • The night-sky bake (skyNightBake — the milky way band/wisps/dust equirect, 2048×1024 at the high tier) is now reconstructed with a 4-tap B-spline bicubic in sky-node.ts (sampleNightBakeBicubic, weights mirrored from three's textureBicubic at a single explicit level) instead of one hardware bilinear tap. Root cause of the "night sky looks like a pixelated 1080p texture" report (debug-day 5.0 #52): once desktop tiers started rendering at native DPR (debug-day #53), one bake texel spanned ~5–7 physical pixels on HiDPI desktops, and bilinear magnification of the bake's near-Nyquist fractal content showed the texel grid across the whole dome — while the per-pixel clouds, analytic stars, and starlight grain stayed sharp around it, which is exactly the reported contrast. Night-only symptom because the bake is the only textured layer in the night composite and only contributes when the sun is below the horizon; the day sky is the smooth sky-view LUT + per-pixel sun/clouds.

  • Cost: zero memory delta at every quality tier (bake sizes unchanged), one textureDimensions + three extra bilinear taps + ~30 ALU per sky-dome pixel, only inside the uniform night gate (which already runs ~13 noise octaves per pixel). The equirect longitude wrap still rides the sampler's RepeatWrapping, and all taps stay at explicit level 0 (no derivative seam), branchless — no select/toVar exposure.

  • WGSL codegen pin added to sky-node-wgsl-structure.test.ts: the night bake must take exactly 4 if-gated taps in both the background and IBL-capture fragments (fails on a single-tap bilinear build); the existing noise-gating and dominance-analyzer pins cover the new expression tree.

  • nightTextureSize doc updated: past Nyquist the knob no longer buys apparent resolution — reconstruction owns that now; bump the width only for genuinely finer authored content.

  • Stretched sprite quads (align: "velocity" / "segment" / "axis") now degenerate gracefully when their direction goes end-on to the camera. The long-axis law is unchanged where it matters — size + |direction| · stretch meters with the WORLD-space magnitude, so the painted streak length never changes as the camera orbits — but the parent renderer kept that full length even when the direction's screen projection vanished, leaving the quad's angle ill-conditioned: full-length streaks whipping around the screen for particles moving at/away from the camera, axis-aligned rain viewed from above painting full-length horizontal streaks, a path segment viewed down its axis drawing a full quad perpendicular to the bolt, and a hard snap to screen-x at the atan guard threshold. The stretch term now eases to zero (branchless smoothstep over the projected fraction — sin of the direction's angle to the view axis — below 0.25 ≈ 14.5°), so an end-on streak renders as the round size × size billboard, smoothly, with no popping. The basis math is collapsed into one shared builder (engine/materials/stretched-sprite-basis.ts) consumed by both the CPU particle batches and the GPU fx batches, with WGSL codegen pins + a numeric mirror of the law in fx-velocity-align-wgsl-dominance.test.ts.

  • Root cause of "authored terrain normal maps read too weak" (#6677 thread, bug #59): the per-layer chain (NRO sample × normalStrength → candidate weight blend → whiteout combine onto the heightfield normal) is full-strength at default everywhere except the detail-normal distance fade — mix(flat, normalTs, 1 - smoothstep(30, 60, viewDist)) zeroed the detail normal by 60 m camera distance, so authored normals only ever lit a bubble at the player's feet. Tangent basis on the grid substrate (T=+X, B=+Z, N=+Y) verified correct.

  • Fade widened to 60–140 m and eased to a 0.35 floor instead of flat (TERRAIN_DETAIL_NORMAL_FAR_FLOOR): macro normal response survives at range; the NRO mip chain plus the 0.4 roughness floor already handle the single-pixel specular sparkle the old fade-to-zero guarded against.

  • A PBR-library textured layer with no authored NRO (never authored, not yet streamed, or the can't-join fallback) now auto-derives its tangent-space detail normal from the signal already in the albedo array: packed material height (alpha) when albedoAlphaIsHeight, else albedo luma. Two forward-difference taps with explicit gradients (WGSL-legal in the weight-gated branches), slope scaled to 0.05 m apparent relief × the existing normalStrength knob — no new spec surface; normalStrength: 0 opts a layer out. Mip minification collapses the differences at range, so the derived normal self-fades. Tier gating preserved: LOD 0 all candidates, LOD 1/mobile dominant candidate only, LOD 2 and zero-PBR libraries unchanged at zero cost (+2 albedo grad taps per candidate only inside the no-NRO branch).

  • Named taste checks: far-floor 0.35 vs specular shimmer at range (drop the floor before narrowing the band if it shows), luma-derived bump on legacy layers inside PBR libraries is a deliberate look change, and the open suspicion that the NRO G-channel is inverted vs the MCDN bake convention still needs one in-scene look.

  • Root cause of the debug-day "contour rings on terrain at grazing angles" (#6677 thread, bug #58): the macro albedo variation (fractalNoise2 at 5/m, second octave 10/m, ±7% luminance) is a per-pixel value-noise lattice sampled with no band-limiting — at grazing view angles the screen footprint sweeps through the lattice Nyquist as a function of distance and prints world-anchored concentric ring moiré (0.1–0.2 m pitch), decaying to per-pixel static at the horizon. The parallax march was a secondary contributor inside its 18 m bubble: every pixel entered the ray at exactly rayHeight=1, offset=0, quantizing intersections into shared stepSize bands.

  • fractalNoise2Banded replaces fractalNoise2 (deleted — no other consumer): each octave fades to its mean (0.5) over a 0.25→0.75 lattice-units-per-pixel footprint band. Mean-preserving — far/grazing pixels converge to the same average shading the unfiltered noise dithered around. terrain.ts computes the footprint (max |dFdx|,|dFdy| of worldPos.xz) once in uniform control flow and feeds the LOD0 fractal, the LOD1 single-octave macro, and the legacy-library layer dither (6/m lattice, same aliasing class). ALU-only, no new texture taps, all tiers.

  • The parallax march entry is jittered per pixel (interleaved gradient noise on screenCoordinate, no texture tap): residual step quantization decorrelates into sub-band noise the existing 2 binary refinement taps converge away. Step counts and tiering unchanged (8→16 adaptive, desktop LOD 0 only).

  • Rider fix found by the new WGSL dominance gate: the biplanar side-axis pick used select(), whose r0.184 if/else lowering was emitted inside candidate 0's side-projection branch — candidates 1/2 read zero-initialized component vars (wrong side-uv gradients → wrong mip/aniso on steep faces) whenever candidate 0 skipped its branch. Now branchless (mix on a 0/1 float). The terrain heightmap material now runs the statement-dominance analyzer in tests (terrain-relief-shading.test.ts), same gate as water/voxel/fx.

  • New path() emission source (engine/fx/path.ts + FxSourceShape kind "path"): a script-supplied polyline — or list of polylines — in meters from the anchor. The engine has no opinion about the shape: lightning channels, crack webs, vines, and laser graphs are all just points the script computed. Each spawned particle is one SEGMENT (a consecutive point pair) — positioned at the segment midpoint and carrying seg (the segment vector), along (0..1 toward its polyline's tip), and channel (the index of its polyline) as spawn attributes, set before init runs. Emission walks the segments in point order and restarts at the first segment on every burst volley, so a volley of exactly segment-count particles draws the whole path once, and populations sharing the same points trace the same shape (a hot core inside its soft halo). The points are read live from the def: a fx.params patch re-runs effect(ctx) and the swapped def drops the cached segments, so the next volley traces the new path — that's the re-aim/re-roll hook.

  • New sprite align: "segment": the quad's long axis follows the particle's per-particle seg vector (long-axis length = size + |seg| · stretch, so stretch: 1 spans the segment exactly). Rides the existing per-particle-direction render path (ParticleGroupAlign 2) with the snapshot writer sourcing the direction from seg instead of the velocity — zero renderer changes. Validation requires seg to be defined (path source or init/inherit).

  • GPU routing: path sources and segment-aligned sprites are CPU-backend populations (explicit ineligibility reasons, like ribbons); segment counts are tiny and lifetimes short. Effects remain visual-only per client. One-shot path draws (and windowed re-volleys) keep deriveFxProgramCompletionSeconds finite, so they self-reap through the existing wind-down/fx-reap machinery.

  • fx skill + examples: a forked-lightning worked example that computes its own midpoint-displaced channel + forks in ~15 lines of script (the creative constants — kink, fork count, fork run — live in the script where they can be taste-tuned) and feeds path(channels) to a white-hot core + tinted halo with the flash light; the tesla-fence example re-scoped to straight beams. Chain lightning is computing more paths.

  • api.getIK() no longer reads DrawIK. DrawIK is render-plane state (realm-local, asset-arrival-dependent — ik-targets' own invariant is "no gameplay reads DrawIK"), but the public API fed it straight into mode-both behavior code: a behavior writing ik.target/ik.reachable into replicated state produced different values per side depending on whether/when each realm loaded the model — a permanent replicated-state mismatch resim cannot reproduce. getIK now resolves from IKIntent + replicated transforms at the shared TomeTick basis via resolveIKTargetPoint, the single resolution implementation shared with the DrawIK derivation (which layers the bounds-center/bind-pose-bone anchor on top as a render-only refinement). The bind-pose estimateIKReachable is deleted; reachable is constant-true at the sim plane.

  • view_live_scene captures now carry an unready-scene-assets annotation in the existing note channel (renderer worker → scene-view.ts → tool caption, no chat changes). Savi kept looking at a wall whose Magic CDN texture was still generating, reading the placeholder as broken work, and fighting it.

    • Truth source is RendererAssetService.getUnreadySceneAssets() — subscriber-scoped (every scene consumer holds a subscribeOnce subscription until its asset loads, the same liveness criterion the retry loop uses), so the note only names assets something rendered is waiting on right now.
    • Classification matches the pipeline's real signals: a /cdn/ asset that hasn't loaded is "still generating" (the Magic CDN generates on first fetch and the service retries CDN assets forever); a terminal generation failure — the 502 tombstone, now captured per retry entry off three's FileLoader HttpError — reads as FAILED, never as generating. Non-CDN assets read loading/failed.
    • Elapsed time rides a new textureFirstRequestedAtMs first-request clock (mirror of the model one): "texture-wall-stone-mossy.png ~42s", stable across retries.
  • Applied to every successful capture source (viewport, camera, frame) in captureSceneViewFrame; wording lives in the pure scene-view-asset-note.ts builder, pinned by tests alongside the service classification.

  • api.pushLook now returns the same deterministic layer id on server and client (minted from the replicated TomeIdSeq, the uniqueId lane) instead of "" on the server and look/N from a module-level counter on the client. Behaviors run mode-both and store the return value in replicated state (state.lookId = api.pushLook('noir')), so the side-asymmetric value guaranteed a permanent replicated-state mismatch on that entity; the unrolled counter also meant a resim replay minted a different id than the timeline whose cleanup timer captured the old one — at fire time it cleared a layer that no longer existed and the real layer leaked (stuck screen effect). The look layer itself stays client-only presentation; pushLookLayer now requires an explicit id (the counter fallback is gone — every caller passes a stable or deterministic id).

  • Author-time misattachment affordance (debug-day ledger #115): authoring a parent link (spawn({ parent }), attachTo/setParent, setProperty("parent")) now runs a geometric sanity check (tome/api/attachment-sanity.ts) and warns through the mutation-warn rail (getLogs) when the link can't plausibly be an attachment. Two checks, signal only — no auto-correction, no spec mutation, no per-tick cost:

    • Escaped child: the child's AABB is fully disjoint from the parent's in the parent-local frame, with a closest-approach gap longer than one full parent-length (2m floor). The classic shape is a world coordinate passed as a parent-relative offset; the warning names both objects, the authored offset, the gap, and suggests "sibling at that position, or a smaller local offset". Skipped for explicit-pivot children (orbit rigs) and bone/socket attachments.
    • Oversized child: the child's bounding volume is ≥25x the parent's (≈3x per linear axis) AND the child is ≥1.5x bigger on every axis. The per-axis gate keeps the legitimate big-child-on-thin-spine archetype (canopy on trunk, sail on mast) quiet. The warning names both objects, both sizes, and the ratio.
  • Both checks compare unscaled local raw bounds (readLocalRawBounds, newly exported from tome/api/world-bounds.ts) in the parent-local frame where the parent's own scale cancels; the child's authored local scale is applied. Either side without derivable drawable bounds (anchor/manager objects, models whose bounds haven't landed) is skipped silently — false positives teach Savi to ignore the channel, missed warnings cost nothing.

  • Spec-load (applySpec/rebindExplicitParentObjects) does not pass through the checks, so existing persisted content never re-warns on room boot.

  • Per-script behavior fault granularity (debug-day #100): fault/park state is keyed (entity, script) end-to-end — one throwing script no longer kills its siblings' hooks. The dispatch loop continues past a faulted entry, timer/event/job callbacks carry their arming script's ref (withActiveScriptRef), park logs name the script in getLogs, and editing the parked script unparks exactly that scope. Sweep-E rollback semantics preserved (side-local deterministic re-marking).

  • Singleplayer rails are now mode-agnostic end-to-end: notifyDm/notifyDmOnce forward from the client authority over the existing Command envelope (tome.dm.notify, server-side once-key dedup in the shared resource set, 20/60s rate window, 2000-char cap, every drop surfaced) (#120); spec mutations forward over a new RoomClientOpcode.SpecMutations to the durable sink while the divergent server-mirror echo is discarded — one persistence mechanism, multiplayer-equivalent (#121).

  • Terrain-edit persistence hardened in two layers: the one-strike permanent kill switch is gone — failures retry with exponential backoff (1s→60s at 30Hz) for the room's life, the write queue survives failures, degradation/recovery surface via getLogs + deduped DM (#122); and a failed boot load is no longer treated as "no durable data" — saves are blocked until a verified base loads, so a transient storage error at boot can never overwrite a previous session's edits (#123).

  • Push-aware delivery (#118): a mismatch tick whose every diverging row classifies push (client provably never wrote the leaf), with no presence rows, no engine-mirrored components, and no buffered writes touching them, is adopted directly instead of booking a full rollback+replay — provably the same end state at O(components) cost. Comparator untouched; F3 shows adopt=N/s. The moving-NPC-near-idle-player resim storm drops to zero.

  • Room engine identity (#119, kernel half): rooms report the engine identity their container booted with in register/heartbeat metadata, so the registry can pin every resolution path (player iframe, Savi fan-out, exec, prewarm) to one DO for the room's whole life — mid-session engine publishes no longer bifurcate auto-update apps. Explicit engine switches force-drain live rooms via cooldown-gated container stop so "switch now" means now (#124, cf-edge/kiln side).

  • Voxel overlap hooks (onOverlapEnter/onOverlapExit) now fire on mantle places via engine-neutral runtime discovery (engine/physics/trigger-overlap.ts) — this was the named gate on the mantle default flip. Mantle trimesh trigger shapes run as their convex enclosure per mantle's primitives-first contract.

  • Renderer hot-path cleanups: op-driven material-compile queue scoped to entity anchors (zero-recompile contract preserved; whole-scene per-frame traverse on animated draw ops eliminated), decoration rebuild guard re-keyed on revisions/identity (per-frame JSON.stringify deleted), look-active drawing-buffer-size Vector2 hoisted to a module scratch.

  • Savi truthfulness rails: saveDocAndNotify retries the room poke once and reports per-room live-update failures in the tool result instead of claiming plain success; a failed draft-storage read is a first-class error that can never silently clobber the published file; model-collider GLB load failures surface via the diagnostics rail with a wire-replicated placeholder status instead of degrading silently to a unit box.

  • Schema parity: AudioSpec carries the full engine AudioIntent surface (refDistance, rolloffFactor, bus, layerMask, priority, vibe paused); PlaceDefSchema carries physicsEngine and brush-painted fields — both were one mutation away from the kiln gate rejecting valid specs.

  • Hygiene: chat v2 debug gizmo auto-refreshes while a turn is active; lag-compensation recording idles on singleplayer worlds; raw NUL bytes removed from physics-dispatch source (with a source-byte hygiene test); dead getEnvVarsForVariant and leftover gt3 debug logging deleted; selection/hover outline post pass dominance-gated.

  • Vehicle prediction parity (debug-day #131): api.getVehicleSpeed() now reads PhysicsVehicleConfig.vehicleSpeed — published by the wheel-state writeback, replicated, rollback-restored, f32-quantized — instead of rapier's controller-internal speed, which resim recreation zeroed (every correction re-injected engineForce/steering/downforce divergence: the driving-car vertical-sawtooth storm). And fresh physics bodies apply their full body/collider config before their first step — they previously stepped tick one at collider-density mass with zero damping (~29× forces), kicking vehicles at a different tick per side when a client materialized one mid-drive.

  • Authored custom cameras keep their framing (debug-day #127): the renderer-authoritative "orbit camera" path now requires the camera behavior to actually read the look axes (input.axes.lookX/lookY, latched per compiled behavior) before it may take over — shape-matching on pointerLock + yaw/pitch state alone hijacked fully-authored cameras (shot-follow, cinematic rigs) and discarded every pose they wrote. Genuinely mouse-driven cameras keep the display-rate path unchanged.

  • Targeted juice reaches multiplayer (debug-day #128): queueEventAdd fires now mirror into the change log at commit, so server-fired screenFlash (player/nearby/place audiences), purchase prompts, and server-side particle bursts ride the wire — they previously fed only the world-local drain queue that nothing server-side consumes (singleplayer masked it because the client is the authority). Event rows for predicted entities bypass the ingest refusal and dedup on the established tick:kind:source#seq key; unresolvable player-audience targets warn instead of dropping silently.

  • Server event queues are bounded (debug-day #129): undrained event fires expire at commit on the component's own ttlTicks, stamped on the local commit clock (safe across prediction lead and resim replays) — server worlds leaked one row per fire for the room's life. A doubling high-water warning surfaces pathological fire rates.

  • destroy() cascades through the persisted spec (debug-day #126): destroying a parent retires every authored descendant row (children-first) with its own recorded mutation + spec-mirror removal — previously the live cascade reaped child entities while their persisted rows survived as orphans pointing at a dead parent, and orphans reload as ROOTS at world origin with parent-local rotation read as world rotation (the "car parts pinned at 0,0,0" corruption, and the long-unreproduced 4.6 rotation-persistence report). applySpec now warns into getLogs when a row's parent can't resolve, so already-corrupted documents self-identify and Savi can sweep them.

  • ObjectAPI.destroy() now cascades through the persisted spec: children persisted as their own parent-linked spec rows (the flat shape spawn() persists under withPersistence / run_script persist) leave the spec — and the recorded mutation stream — together with their destroyed parent. Previously the live cascade reaped the entities (destroyEntityWithHook recursion never re-enters destroy()), but only the target's row was mirrored/recorded out, so child rows survived as dangling-parent orphans. Every later load re-instantiated them with an unresolvable parent; hierarchy-solve skips children whose parent entity is missing, so their authored LOCAL feetPosition rendered as world coordinates — parts pinned at the world origin forever (DD5 ledger #126, app bf76ad30 "Tucker Circuit", dump 12ae2d30's test-veh/tc-* rows). The cascade covers grandchildren and rows whose live entity is already gone; rows are mirrored children-first so an undo replay respawns the parent before its children.

  • applySpec surfaces dangling parent links: after the full reconcile + rebind pass, any spec row whose parent is still unresolvable records a runtime warn (getLogs-visible to Savi, tome.reconcile.parent_missing server-side) naming the row, the missing parent, and the consequence. Rendering is unchanged (the parent may legitimately be authored later) — the signal is the fix, per graceful-degradation-surfaces-signal.

  • New pinning suite singleplayer-assembly-adoption.test.ts: full-stack server-mirror + client harness (real netcode pipe, real tome feature via glue, real rapier vehicle, real renderer smoothing) proving a behavior-built parented assembly stays rigid in sim AND drawn poses through the singleplayer join-snapshot flip and through a reload against a long-diverged mirror — the rest of ledger #126's surface, pinned healthy.

Atelier: engine v4.6.0

Released June 1, 2026

  • God mode is a real in-world editor now. Grab any object and move, rotate, scale, or stretch it with on-object handles. A toolbar at the bottom gives you shapes, paths, and brushes, and your tools live in tidy tabs in the top-right. Made a mistake? Ctrl+Z to undo, Ctrl+Y to redo (top-left).
  • Paint your world. Sculpt hills and valleys with terrain brushes, paint different ground materials, and scatter plants and objects across the land. Right-click anything you've scattered to tune the whole patch at once — amount, size, spacing, sway, and how it grows on slopes.
  • Sculpt while you play. Terrain you raise, lower, or repaint updates live underfoot — and since edits sync to everyone, you and your friends can build the same world together in real time.
  • More material looks — give objects toon, phong, matcap, and other lighting styles, not just the standard one.
  • Per-object shadows — turn shadow casting and receiving on or off for any object — handy for glass, signs, and effects.
  • Animations just work. Name a clip loosely (say "walk") and it still finds the right one instead of freezing in a T-pose, and you can layer upper-body-only animations over a full-body one.
  • Walk-through archways — archway openings reach the ground, so players walk straight through the doorway.
  • Smoother and sharper — a rebuilt renderer keeps frame rates steady, holds detail on distant models, and keeps memory in check on big scenes. Custom camera styles run smoothly again.
  • Sculpting feels right on hills and cliffs. The brush lands exactly where you point — even on steep ground — strokes stay strong on slopes, and the brush ring hugs the terrain instead of floating as a flat circle.

technical notes

God mode: in-world editing surface (#6496, #6576)

  • Rebuilt god mode as a direct-manipulation editor — anatomy-grown handles/gizmos (translate/rotate/scale/extrude), hover affordances, and a bottom toolbar (paths/shapes/brushes + blueprint-category tabs). All authoring flows through replicated, deterministic Tome authoring input (server-authoritative, multiplayer co-edit), not the old client-only selection-inspector transport.
  • Placement: primitives, blueprints (full subtree clones), and prefabs via cursor-follow + click-to-commit, right-click cancel. Splines: freehand draw, extend-from-endpoint, snap-close, shape recognition, conform-to-terrain. Attach/detach, subtree-aware duplicate, parent-aware transforms, R-to-rotate.
  • BREAKING: god-mode authoring UI moved to creatorTabs (CreatorTabDef[] on GameSpec, compiled to CompiledSpec.creatorTabs); setCreatorTab(id, tab|null) adds/replaces/removes one tab. The old creatorUi and mode:"god" modUis are no longer compiled or rendered into the rail (hard-cut; creatorUi remains in the type as @deprecated). One module per tab — default export = render, named onMount/onDestroy for lifecycle. Rail moved from a bottom-center pill to a top-right tab rail (buildCreatorTabRailHtml).
  • Added god-mode undo/redo: top-left controls + replicated god:undo/god:redo actions (Ctrl+Z / Ctrl+Y) on the player-keyed UndoStacksResource (the same stack api.undo() drives). Property edits only; structural spawn/delete is a follow-up.
  • Fields primitive: named authored+runtime spatial rasters (terrain:height, terrain:material:<id>, scatter:<bedId>, plus gameplay fields), component-backed inside the rollback envelope; every field op is O(update) via per-chunk versioning. New readField/writeField ObjectAPI; writeField inside withPersistence authors (→ place.fields), otherwise writes the runtime layer.
  • Runtime terrain editing: terrain mesh + collider read the composed (authored+runtime) field, so a gameplay/behavior writeField("terrain:height"|"terrain:material:<id>") sculpts live (only authored edits persist). Static { terrain: offset } objects re-anchor when the composed ground moves (terrainReanchorSystem, version-gated); physics bodies settle on the composed collider.
  • Brushes + scatter: godMode.brushes registry (defineBrush/updateBrush/getBrush) + paint-only scripts (export function paint(p, inset, current, ctx)). Ships terrain/material/daisies built-ins (materials derive from the terrain palette). Scatter = real entities (≤500 cap), edited via one generic scatter editor (Amount/Pattern/Size/Sway/Steep/Grow/Shrink); TomeOwnedField auto-prunes a bed's field on destroy. Added getObjectWithDescendants (deep-clone subtree).

Renderer → adapter rewrite (#6517)

  • Replaced the 13 hand-rolled state/* translator classes with op-driven per-kind handlers that write Three.js directly off the ECS delta stream (no RenderPrep/RenderReadView/DrawableSource intermediate; per-handler typed userData). Renderer dropped from ~66k to ~20k LOC. TransformSmoother emits synthetic transform/world-* ops into the same per-op write path. No spec/script migration required — games render the same.
  • Node-material resolver: DrawMaterial.key maps to lit material families (standard/PBR, Phong, Lambert, Toon, Matcap, unlit) plus water/slash/shockwave/voxel paths, with consistent override application. isEmissiveMaterial (flash + glow) broadened from MeshStandard-only to any material with emissive/emissiveIntensity.
  • Per-entity draw/shadow component (castShadow/receiveShadow, default on), authored via ObjectProperties.castShadow/receiveShadow; honored across standalone, indirect-batch (in the batch lane key), and oversized primitive lanes. Effect/water materials keep __noShadow as cast default.
  • Animation: mixer now substring-matches a missing clip name ("walk" → "Walking") and falls back to the first viable clip instead of rendering T-pose; restored mask bones (per-bone clip filtering for upper-body-only overlays) and LOD migration (camera-driven reconcile, thresholds 25/50/100, LOD3 resident to 200m).
  • Bounded texture VRAM via handler retain/release at every bind site + re-enabled eviction; batched MSDF world-text glyphs; GPU scatter for terrain decorations; place-transition stream reset (keeps same-frame recreated entity objects alive).
  • Fixed archway primitive geometry (doorway opening reaches the floor) and a regression that broke kind: "custom" cameras.

God-mode polish + sculpting feel (#6587, #6588, #6585)

  • Particle emitters get their own editor cluster (move pad, vertical lift, one shape-aware size grip that grows the emission shape, rate chip, verbs — no yaw/scale grips); selected emitters draw their emission volume as a translucent shell. Smoke placeable emits from a box volume.
  • Toolbar pipeline unified: default + spec prefabs group by category into tabs; "Shapes"/"Paths"/"Brushes"/"Effects" are joinable category names, so creator/Savi blueprints land inside the built-in shelves.
  • Hover affordances stay quiet while a selection is active; co-located handle fan-out uses a screen-space threshold so nearly-identical anchors separate consistently; a controls cheat sheet sits under the top-left undo/redo cluster.
  • Brush sculpting feel: dabs land on the first ray–terrain crossing (the cliff face under the pointer), the cursor projects against the sculpted surface frozen at stroke start, built-in Raise/Lower compensate for slope, and the brush ring drapes over the terrain.
  • Savi: the god-mode briefing fires only when a game has no creator tabs and steers her toward a per-place "Places" tab, blueprints/brushes in a zoo place, and withPersistence for panel edits; heightmap-terrain skill triggers incidental terrain and tames domain-warp spikes.

Engine v4.5.2

Released May 27, 2026

  • Animated models always play something — falls back to an available animation instead of standing still.
  • Savi can now catch editing mistakes that made parts of your world disappear or stop moving.
  • Savi uses clearer, more specific commands when editing your world's atmosphere, terrain, camera, and other settings.

technical notes

  • Fixed silent T-pose when a draw/mixer channel references a clip name that doesn't exist exactly on the loaded model. Renderer now substring-matches (e.g. walk → Walking) and falls back to the first usable clip; skips rest-pose placeholders (zero duration, empty tracks, or every-track-single-keyframe).
  • Reject misplaced api.spawn() fields transactionally and make invalid pathless api.patch() calls report the runtime mutation API directly to Savi.
  • BREAKING: Removed api.patch(path: string, value: Record<string, unknown>) from ObjectAPI.
  • Restored eight per-slice patch methods: patchAtmosphere, patchTerrain, patchPlayer, patchCamera, patchInputs, patchGodMode, patchUi, patchEngine.
  • Per-place targeting uses the second argument again: api.patchTerrain(p, "main") and api.patchAtmosphere(p, "main") replace the dot-path form.
  • Internal recorded mutation kind tags unchanged — persistence/replay/serialization stay stable.

Engine v4.5.1

Released May 23, 2026

  • Ponds run much smoother without changing how they look.
  • Plants and ground decorations now stay on the ground when you reshape terrain.

technical notes

  • Improved water rendering performance by rendering transparent double-sided water in a single pass and omitting inactive TSL viewport color/depth copy nodes from lightweight water material variants.
  • Shares one viewport depth capture across rich-water depth samples, reducing duplicated full-screen depth resources during rendering and resize.
  • Rebuilds water material viewport topology when depth, shoreline foam, or refraction features change so live edits retain the intended appearance.
  • Keeps renderer resize at the same capped pixel ratio used during startup, preventing high-DPR screens from unexpectedly quadrupling full-screen water and post-processing work after resize.
  • Fixed terrain decorations using stale height data after live terrain reshaping.

Surface Tension: engine v4.5.0

Released May 22, 2026

  • Water you can swim in — add ponds, lakes, and oceans to your worlds. Players dive under, float back to the surface, and splash going in, with waves rolling across the top and foam gathering at the shore.
  • Shapes that look like real things — the shapes Savi builds by hand can now look like wood, stone, or brick instead of a flat color.
  • Patterns fit whatever size you build — stretch a crate and the wood grain spreads across it naturally instead of smearing.
  • Recolor one copy without touching the rest — give a single barrel its own shade without making a whole new one.
  • Surfaces look cleaner from across the room — detail holds up when you're looking at things from an angle.

technical notes

Water / Liquids

  • New schema-driven liquid system (#6418). Water bodies are configured through liquid-preset controls instead of hardcoded values — gated stylized contact foam and crest foam (with crest-foam texture support), waves, caustics, refraction, all runtime-tunable. Swimming is supported via onLiquidEnter / onLiquidExit behavior hooks (liquid.feetPosition, buoyancy, splash). Surface uses non-analytical normals for performance. New water-and-swimming skill; heightmap-terrain skill updated for decorative water placement.

Primitive & geometry textures

  • Albedo textures for scripted geometry (#6534). Scripts exporting geometry() can now apply albedo (and multi-texture) maps to custom geometry.
  • Per-instance texture tinting + native UV scaling for indirect batched primitives (#6538). Luminance-preserving per-instance tinting via the shared applyTextureTint math (lifted to renderer/utils/tint-node.ts, now shared with terrain). UV scaling derives from the entity's transform scale by default (computeUvTransform / inferNativeUvScale) — a 4×2×3 box tiles 4×2 instead of stretching one tile. setUvTransformAt writes the tint-strength lane in the same call.
  • Improved primitive texture anisotropy (#6541) — textures stay sharp at grazing angles.

Engine v4.4.3

Released May 21, 2026

  • Fixed some characters appearing too small.

technical notes

Tome / ObjectAPI

  • Self-heal poisoned cached model bounds (#6536). Added a boundsVersion field on assets.metadata[*]; entries persisted before the current version are treated as missing by hasExistingBounds and re-prefetched through the current bounds pipeline on next load. Fixes characters whose previously-cached bounds were oversized — layout.maxExtents was shrinking them to fit, so they rendered tiny.

Engine v4.4.2

Released May 20, 2026

  • Build bigger worlds with lots of places — areas no one is in quietly sleep so the game stays smooth, and snap back to life the moment someone walks in. Your spawn area is always ready.
  • Joining a game is more reliable — fewer hangs on the loading screen when you click play.
  • Busy worlds stay in sync — when lots of players are exploring different corners of the same game, things no longer snap or jitter on screen.
  • Avatars don't vanish on you anymore — Savi catches her own slip-ups when updating your character, instead of accidentally making it disappear.

technical notes

Engine

  • Place residency (#6528): added PlaceResidencyResource tracking which places have materialized ECS state on the server. Non-default places lazy-load on enterPlace / spawnPlayer — ECS entities, physics runtimes, terrain chunks, and atmosphere are only created when a player enters. Empty non-default places unload after 300 ticks (~10s) with no players: entities destroyed (onDestroy fires), physics/terrain/atmosphere freed. Ephemeral places additionally drop from the spec on unload; authored/session/persistent places retain their spec definition for re-loading. Default place is always resident. ensurePlaceResident() triggers a spec update so the place materializes in the same tick. Observability: tome.place.resident, tome.place.unload_scheduled, tome.place.unload.

Networking

  • Fixed connection-startup races (#6523) where client messages (e.g. fullsync requests) arriving before connection setup completed were silently dropped at three layers: container WebSocket open handler, runtime worker attach flow, and room-runtime pending-connection queue. Early-arriving messages are now buffered per-connection and replayed in order once the connection materializes.

Tome / ObjectAPI

  • Fixed non-deterministic api.random() under Area of Interest (#6529). The behavior-update system previously used a single shared RNG stream per tick; when client and server iterated different entity sets due to AOI, subsequent entities drew from different stream positions, causing permanent state divergence. Each entity now gets an independent PRNG seeded from (tick, entityId) via rngFor, making random output invariant to which other entities are present.
  • api.patch("player", ...) now validates against PlayerDefSchema.strict() at the API boundary (#6526). ObjectProperties keys passed at the PlayerDef root (e.g. model, feetPosition, physics) used to merge silently and never render — the patch landed in the spec but the engine never read them. Invalid patches now early-return and emit a mutationWarn to Savi's run_script log naming the offending keys with a "nest under properties" hint. Shares the strict-schema precedent used by patchTerrain; renamed the helper formatTerrainValidationIssues → formatZodIssues (4 callers updated).

Renderer

  • Packed PointLightDataNode, SpotLightDataNode, and DirectionalLightDataNode into single stride-N uniformArray("vec4") bindings shared by JS writer and TSL reader through a typed STRIDE/SLOT constant (#6525) — writer/reader drift is no longer expressible. Fragment UBO bindings for batched dynamic lighting: point 3 → 1, spot 4 → 1, directional 2 → 1. Per-point-light memory 48B → 32B. Lighting math, countNode Loop trip count, and DynamicLightsNode.customCacheKey() are untouched — no material recompiles when light counts change. Dropped // @ts-nocheck from all four DynamicLighting/data/* nodes; they now type-check under strict TS via narrowed builder.context access and a small BatchedLightSentinel Node subclass for the lightNode slot of LightingModelDirectInput.

Engine v4.4.1

Released May 16, 2026

  • Savi places things on top of anything now — models, primitives, sprites, custom geometry, or text, she knows the size and lands the placement first try.
  • Custom uploaded models that disappeared after the animation migration are back — your characters keep their model and animations switch correctly.
  • Non-square images render on walls and floors again — photos, screenshots, and posters no longer come out as blank white spots.

technical notes

Renderer

  • White-floor fix on chat-attachment surfaces (#6511): new OversizedPrimitiveConsumer routes entities whose loaded textures don't fit the texture-array profile (512×512 desktop / 256×256 mobile) to a dedicated InstancedMesh with a stock MeshStandardNodeMaterial. mapRepeat / mapOffset packed into the pool key so seamless-tile walls tile correctly. scene.ts split into per-consumer modules (water / effect / batch / oversized) behind a ConsumerDispatcher; 575 → 423 lines, @ts-nocheck dropped.
  • Renderer unification (#6506): deleted legacy engine/render/ (~28k LOC of the @ts-nocheck WebGL main-thread renderer); renamed engine/render_v2/ → engine/renderer/. Identifier sweep: RendererFeatureV2 → RendererFeature, worker-renderer-v2.mjs → worker-renderer.mjs, [render_v2] → [render]. Zero render_v2 references anywhere. Removed the broken playground/ dev harness.
  • New TransformMiddleware (engine/renderer/transform/) owns pos/rot/scale smoothing in one place. ScaleSmoother parallels the pos/rot smoother (exp-damp, snaps on >2× axis-ratio change); scale interpolation is opt-in via the existing draw/interpolation component, and sticky-disable propagates so "disabled" keeps all three channels snapped.

Physics

  • Auto-collider rebuilds when model bounds arrive (#6507). The autoCollider: { kind: "model", modelId } marker was never acted on — clamped colliders stayed at the 0.5m placeholder forever. Now subscribes to the next BoundsRegistry.setBounds on both planes; second-pass applyPhysicsSpec emits real half-extents. WeakMap dedup; teardown / despawn / model swap disposes the listener. Fixes resim spinning at 85-100/s on spawn into a physics: "static" clamped-collider model.

Tome / ObjectAPI

  • setProperty('animated3DCharacter', false) no longer wipes DrawModel on entities whose model was authored independently (#6508) — tear-down now gated on DrawAnimated3DCharacter actually being present. Restores the 4.3.0 compat promise.
  • model.animation updates propagate on every write, not just the first (#6508). derive-appearance.syncAnimationForModel no longer bails when a DrawMixer is already present — upserts _default, preserves authored channels. Per-tick applyClip with unchanged clip/speed/loop skips the ECS write.
  • api.getWorldBoundsBox() now returns WorldBoundsBox ({ min, max, size, center }) for DrawPrimitive (tube uses point-cloud min/max instead of bottom-center), BespokeGeometry / DrawMesh-only models, DrawSprite (anchor-aware, billboard-mode-aware: full → sphere, yaw → cylinder in xz, none → flat quad), and DrawText (Geist Pixel metrics: charWidth ≈ 0.4 × size) (#6505). Internal OBB→AABB projection uses full local center so non-bottom-centered bespoke meshes project correctly.

Solid: engine v4.4.0

Released May 15, 2026

  • Stacking just works. A mug on a chair on a house keeps its size. Ask Savi to put something on top of something else and she'll land it first try — huge for any game where things sit on shelves, stack into towers, or get carried around.
  • Worlds load clean. Things appear at the right size right away. No more pop-in where stuff suddenly grows or shrinks as the level comes in.
  • Clicks land where you point. The sky doesn't steal clicks anymore, and big crowds of repeating things (trees, rocks, props) can be clicked one by one. Shooters, click-to-place, and pick-up-anything games all feel sharper.
  • Effects stick to surfaces the right way. Bullet holes, splats, dust puffs and decals face the wall they hit instead of floating sideways.
  • Camera stays still when you open the menu — no more drift while you're trying to read.
  • Heads up: a few old scenes might look a little different where you'd attached one thing to another. Take a peek and tweak if anything looks off.

technical notes

Scene graph + layout (breaking)

  • BREAKING: scene graph rewrite (#6480). Replaces Scale + LayoutScale + RenderScale + LayoutScaleFeature + RenderScaleFeature + the multi-stage hierarchy composition with three explicit components, each with one job:
    • LocalScale / LocalRotation / LocalFeetPosition / LocalPivot — authored inputs.
    • WorldScale / WorldRotation / WorldFeetPosition — solved hierarchy outputs (World* = parent.World* ⊗ Local*). Pure tree math, no asset awareness.
    • GeometryScale — per-asset fit factor derived from (DrawModel.rawBounds, TomeLayout). Computed for every entity, leaf-only at consumption, never inherited.
  • BREAKING: fixed layout cascade bug — a root's layout.maxExtents no longer shrinks every descendant. Renderer / physics now read WorldScale × GeometryScale per entity instead of a composed RenderScale that mixed asset-fit into the hierarchy. Regression test: tome/__tests__/mug-on-chair-on-house.test.ts.
  • BREAKING: fixed parented-clamp drop bug — a parented entity's own layout.maxExtents now applies. GeometryScale is written regardless of parenthood. Regression test: tome/__tests__/parented-layout-clamp.test.ts.
  • BREAKING: maxExtents is a ceiling (factor = min(1, maxExtents / rawBounds)), minExtents is a floor (factor = max(1, minExtents / rawBounds)), minExtents === maxExtents = exact fit.
  • Added api.getWorldBoundsBox(id): { min, max, size, center } | null. Backed by tome/api/world-bounds.ts; WorldBoundsBox type surfaces in Savi's prompt via the shared-schemas section.
  • Renderer reads WorldScale × GeometryScale uniformly. Model matrix = T(WorldFeetPosition) × R(WorldRotation) × S(WorldScale × GeometryScale). Feet alignment uses the combined factor.
  • Rapier reads WorldScale × GeometryScale so collider geometry matches visible geometry on every entity. Pose stays in pure graph-transform space; feet-to-body-center offset = (rawHeight/2) × WorldScale × GeometryScale.

Asset bounds prefetch

  • New server-side BoundsPrefetchFeature (#6490) discovers entities with DrawModel, submits Range-fetch jobs that parse GLB headers (~64KB per model) off the main thread, and writes results to GameSpecResource.assets.metadata. Supports KHR_mesh_quantization dequantization.
  • Bounds persist to Supabase via patchAssets, so subsequent room starts find them cached and avoid the refetch.
  • Hooks-driven, not per-tick (#6491): onComponentAdd/onComponentSet(DrawModel) + a one-time seed of existing entities. Steady-state cost is zero. Catches applySpec, api.spawnObject, and runtime setProperty("model", …) swaps without scanning script source.
  • Removed the now-unused collectModelUrls / MODEL_URL_INLINE_RE spec walker.

Raycast

  • Raycast results now include per-hit surface normals (packed + decoded) for both CPU and GPU paths (#6504).
  • CPU/GPU raycast behavior aligned for first-hit queries; both use snapped ray directions so results are consistent across paths.
  • Sky meshes are excluded from raycasts — click queries no longer hit the skybox before the world.
  • Raycast reset and distance handling hardened against invalid (NaN/Infinity) values.
  • Fixes raycast against IndirectBatchedMesh in the compute raycast path.

Input

  • handleMouseMove in engine/input/raw-capture.ts now gates on getInputMode() === "overlay" like the other input handlers (#6501). Pointer-lock mouse deltas no longer rotate the camera under an open overlay.

Skills (Savi-facing)

  • Removed six phantom API method names from the engine skill files (#6500). All were taught to Savi but didn't exist on ObjectAPI / TomeCameraAPI, causing tool-call failures or silent [Tome] patchTerrain() ignored… rejections:
    • world-composition.md: api.setSpec("...decorations") → api.patch("terrain", { decorations: … })
    • voxel-terrain.md: api.patchTerrain({…}) → api.patch("terrain", { addMaterials, addMarks, … })
    • voxel-terrain.md: objectApi.getPointerRay(input) → objectApi.getInputRay(input)
    • pointer-raycasting.md: getPointerRay / getAimOrigin → getInputRay / getProperty("feetPosition") + offset
    • pointer-raycasting.md: objectApi.raycastPhysics(…) → objectApi.raycast(…)
    • 3d-billboard-sprites.md: objectApi.getPosition() → objectApi.getProperty("feetPosition")
    • turrets.md: objectApi.damage(id, n) → getObjectState + patchObjectState({ health: … - n })

Engine v4.3.1

Released May 13, 2026

  • Walls and floors are back in 3D Rooms games. Players land cleanly, walls stop the player, and skeletons stand on the floor instead of falling into it.

technical notes

  • Fixed 3d-rooms terrain colliders silently dropping out of Rapier (#6483). resolveColliderMesh + isMeshColliderReady in apps/cf-kernel/src/engine/physics/rapier/bodies.ts gated bespokeMesh readiness on top-level mesh.indices.length > 0, but buildRoomsBodyConfig packs each rooms floor/wall/ceiling box into hulls[] as a convexHull point cloud with empty top-level indices. Readiness check now accepts hulls-only meshes; per-hull threshold matches the convexHull creation path at bodies.ts:1057. Introduced in #6417, surfaced 7 days later from a 3d-rooms game report.

Groovy: engine v4.3.0

Released May 13, 2026

  • Savi is your DJ now. Ask her for a song and she'll score your game live — the music reacts to what's happening, so when things get tense, the music gets tense.
  • No more jitter. Players and cars walk and drive clean.
  • Controls feel right. What you press is what you get.
  • God mode pointer works again. Clicking and dragging in build mode is fixed. Bigger god mode upgrades coming soon.
  • Way more clouds. Skies can be way more dramatic without slowing things down.

technical notes

  • Vibe: new live-coded music system + sample library (#6442). .vibe files, mini-notation, transport API, vibe skill. Vibes read game state and modulate voices in real time.
  • BREAKING: DrawInterpolationValue changed from { teleportThreshold } | null to { kind: 'enabled', teleportThreshold } | { kind: 'disabled' }. See migration notes.
  • Movement smoothing: entity smoother half-life now matches camera smoother — fixes player/car walk jitter (#6462). Interpolation on by default (1.25m threshold; 20m for control targets). All players interpolate, not just self (#6441). New SimTickInterval component + ThrottleResource pipe effective tick rate to renderer.
  • Input pipeline redesign (#6470): raw capture on main thread, worker-side resolution, epoch removed. Preserve input runway after fullsync (#6478).
  • Raycast + god mode unified (#6445): single pointer ray, single drag state.
  • Render: draw/cloud-clusters removed, replaced by GPU-instanced draw/sprite-instances (#6452).
  • Reliability: networking-tail prod fixes (#6451), diagnostic signals for stuck rooms (#6467).
  • Skills: Minecraft prompts route to voxel-terrain (#6464). Stale persistence refs removed from Savi prompt (#6441).

Continuum: engine v4.2.0

Released May 9, 2026

Saved games, smoother animation, sharper aim, and a basket of polish fixes you'll feel right away.

Save your games

  • Player progress persists across sessions — position, inventory, and stats all come back when players return.
  • World state persists too: tag objects to save, set up cron jobs to auto-save on a timer, and graceful shutdowns save everyone before the server goes down.
  • Multi-place persistence — walk through a portal, disconnect, and come back in the same place with your stuff.
  • Static initial values on an object's state field are applied automatically. Add new fields later and returning players get the defaults without losing their saves.
  • One unified state system — no more confusing split between "persistent" and "ephemeral" state.

Animation, reborn

  • One animation system now: named mixer channels driven by updateChannel from your scripts.
  • Two-clip blending (Walk over Idle, Cast over Run) is first-class — give channels names and they layer cleanly.

Smarter pointer & raycasts

  • The "I shot myself" bug is gone — api.raycast excludes the caller by default.
  • Sphere casts now work for aim assist and area effects: { shape: { sphere: radius } }.
  • Click-to-throw, click-to-shoot, and other click actions fire exactly where you're looking. No more first-person balls flying off behind you.
  • The first click that locks the cursor no longer wastes your first action.

Bug fixes you'll feel

  • Signs Savi makes no longer spin their text to face you and clip through the panel.
  • Strands of fairy lights and bunting now actually look like strands — a thin cord drooping through your points with cute bulbs or flags hung from it, no random poles in the scene.
  • Stacks of bottles, mugs, and other small lathe-shaped objects don't explode into orbit anymore. They just sit on the table.

Behind the scenes

Existing games migrate to all of these automatically when you upgrade:

  • One api.patch() method replaces eight separate per-slice patches.
  • One way to make tubes, roads, pipes, and fences: spline. The redundant path property is gone.
  • Eight pointer/aim/raycast methods replaced by two clearer ones.

technical notes

  • REMOVED: DrawAnimation component (draw/animation). Animation state flows through draw/mixer only. Legacy model: { id, animation } is converted to a _default mixer channel in derive-appearance.syncAnimationForModel.
  • REMOVED: Animated3DCharacterFeature's client-side DrawModel deriver. The server now writes DrawModel alongside DrawAnimated3DCharacter so it replicates normally — no per-client derivation, no prediction mismatches.
  • REMOVED from renderer (render_v2/state/model.ts): tickLocomotion, computeLocomotionVelocity, AnimConfig, narrowAnimated3DCharacter, record.animConfig, the "loco" LayerOwner, and the locomotion constants. The renderer no longer knows draw/animated-3d-character exists; it consumes mixer channels only.
  • REMOVED: "draw/animated-3d-character" from RENDER_COMPONENTS — the component no longer crosses the render channel.
  • ADDED: Animated3DCharacterLocomotionFeature (server+client) — back-compat shim that translates DrawAnimated3DCharacter config + entity velocity into mixer channels and handles facing rotation. The only file in the engine that knows a3dc exists; deleting it removes a3dc support entirely.
  • ADDED: 3d-animations skill — covers mixer channel API, locomotion recipe, one-shots, bone masking.
  • ADDED: Fixed humanoid capsule for character controllers without an explicit collider, replacing the model-derived convex hull that produced unstable collisions on terrain.
  • ADDED: _default auto mixer channel for skinned models that have no explicit animation/mixer, so loaded models don't sit in bind pose.
  • DEPRECATED: DrawAnimated3DCharacter / animated3DCharacter. Hidden from Savi's prompt. Runtime continues to accept it via the compat shim.
  • CHANGED: writeDrawAnimated3DCharacter no longer writes DrawModel as a side effect; the interpreter and setProperty callers compose writeDrawAnimated3DCharacter + writeDrawModel explicitly.
  • BREAKING: Removed built-in auto-persistence system (engine.persistence config, player/room/singleplayer save systems)
  • BREAKING: Removed player.onDisconnect BehaviorRef — replaced by onPlayerDisconnected lifecycle hook
  • BREAKING: Removed patchEphemeralState(), replaceEphemeralState(), setEphemeralState() from ObjectAPI — ephemeral state unified into TomeState
  • BREAKING: Removed onDisconnect from compiled behavior hooks
  • Added engine.behaviors: BehaviorRef — lifecycle hooks as named exports (same composition pattern as entity behaviors)
  • Added engine.crons: { schedule, script }[] — scheduled jobs with config-side scheduling
  • Added lifecycle hooks: onPlaceStart, onPlaceShutdown, onPlayerConnected, onPlayerDisconnected
  • Removed objectApi.defaultState() — static defaults belong on the spec's state field; use patchState() in onSpawn for computed values
  • Added objectApi.awaitJob(jobId) — Promise-based job result for async lifecycle/cron hooks
  • Added objectApi.getPlaces() — returns all spec + instanced place IDs
  • Added LifecycleContextResource — built-in storage jobs execute immediately via SDK in lifecycle context
  • Added async detection in entity behavior compilation — async function onSpawn/update/onInput/... rejected at compile time
  • Added skipOnSpawn parameter through attachClient chain — entity creation split from onSpawn for persistence
  • Added connection.rejected control message with client-side error overlay
  • Added graceful shutdown: worker-thread dispose awaits completion, disconnect hooks run for all connected players
  • Added specApplied gate — connections queue until first spec is applied
  • Lifecycle scripts compiled once per ref (module-level state shared across hooks)
  • objectApi/cameraApi parameter naming standardized across all skills
  • Added persistTerrainEdits?: boolean to PlaceCreateOptions — opt-in voxel terrain edit storage
  • Stripped non-voxel persistence from place-persistence system (object spawn/destroy/state tracking removed — use lifecycle hooks instead)
  • BREAKING: Removed eight per-slice patch methods (patchAtmosphere, patchTerrain, patchPlayer, patchCamera, patchInputs, patchGodMode, patchUi, patchEngine) from ObjectAPI.
  • Added api.patch(path: string, value: Record<string, unknown>) — single method that dispatches to the right spec slice based on dot-path. Existing per-slice validation preserved internally.
  • Per-place targeting now uses path syntax: api.patch("places.<id>.atmosphere", v) and api.patch("places.<id>.terrain", v) replace the old second-arg form.
  • patchState, patchEphemeralState, and patchObjectState are unchanged — they target entity state, a different concept.
  • Internal recorded mutation kind tags (patchAtmosphere, patchTerrain, etc.) are unchanged so persistence/replay/serialization stay stable.
  • BREAKING (public API): path removed from ObjectProperties type. Runtime handler retained for backward compatibility — existing games' specs still parse and render.
  • The string-keyed setProperty("path", ...) overload still accepts path at runtime via the legacy registry entry. The typed setProperty<K extends WritableProperty> overload no longer admits "path" (use "spline").
  • PathSpec is no longer @tomeapi-tagged and no longer appears in the generated Tome API prompt.
  • kind: "pipe" in SplineSpec maps to the same tube primitive used by the legacy path handler, so visual output is identical for the common point-array tube case.
  • BREAKING: Removed from ObjectAPI: raycastPhysics, raycastPhysicsAll, raycastPhysicsDown, getAimDirection, getPointerDirection, getPointerRay, getAimOrigin, directionFromYawPitch, rotationFromDirection.
  • Added: api.raycast(origin, direction, distance | opts) with positional and options overloads. Default ignoreSelf: true. Supports shape: { sphere: number } and multiple: true.
  • Added: api.getInputRay(input?) returning { origin, direction } | null. Reads from pointer axes (handles both pointer-locked center and cursor modes via existing input-axis dispatch).
  • directionFromYawPitch and rotationFromDirection available via require('builtin/vec3').
  • getAimDirection(input?) and getPointerDirection(input?) now accept omitting the input argument — falls back to the camera state resolved by getCamera() so they work in update/onCollide/etc., not just onInput. Eliminates the cryptic undefined.axes crash when scripts called these from non-input hooks.
  • Pointer-direction ray now uses rendererTransform.pos/.rot (renderer-authoritative camera SAB) instead of the script-side ECS Rotation. Under pointer-lock the script-side rotation drifts from on-screen orientation because it integrates lookX/lookY without the renderer's MOUSE_SCALE, so click-to-throw / hit-detection rays were diverging from the crosshair. Fixed in resolveViewState.
  • Suppressed the mousedown that acquires pointer lock from also firing as a left-button press. Previously the first click both locked the cursor and triggered whatever action was bound to mouse-left (throw, shoot, place block).
  • Collider override sizing: physics.collider: "box" | "sphere" | "capsule" overrides now derive dimensions from the primitive's actual bounds instead of falling back to the engine's hardcoded 1m defaults. Extended to bespokeMesh primitives (lathe, cone, pyramid, hemisphere, ellipsoid, torus, etc.) via getBespokeGeometryBySignature. Fixes invisible 1m capsules under tiny lathe milk-bottles that overlapped and exploded in stacks.
  • Sign text: when an object has explicit rotation (or yaw), text.billboard defaults to "none" so labels stay flush to the surface they were placed on instead of billboarding through the panel.
  • Stringlight default layout is now a poleless cord+primitive-bulbs strand that follows the authored points exactly. Set poleHeight to opt back into the freestanding pole+wire layout. Bulbs are emissive sphere primitives (cheap), not point lights.
  • Bannerline default layout is now a poleless cord+pennants strand that follows the authored points exactly. Set poleHeight to opt back into the freestanding pole+wire layout.
  • Heightmap terrain.materials now coerces a Record<id, material> shape to the typed array form instead of crashing the runtime when malformed specs arrive (Savi was occasionally confusing heightmap with voxel material shapes).
  • Spline points/beziers doc: added the frame-rule explanation (offsets from the owner; standalone splines anchor at scene root). Same content threaded into the prompt's spline section.
  • New audit test primitive-collider-matrix.test.ts exercises every primitive kind × every collider override (81 cases) and asserts the result is sized — catches future primitives that ship without a deriveColliderFromBounds entry.

Foundations: engine v4.1.0

Released May 4, 2026

Spawn's first big engine update. Faster, smoother, smarter — and a small mountain of fixes.

  • Huge engine perf upgrade. The whole renderer was rebuilt, terrain streams better, and big scenes draw faster and smoother.
  • Savi's tools are way more reliable, and she can now see and poke at your game's UI — huge unlock for UI-heavy games (Discord-style chat games, dashboards, card games, etc.).
  • Multiplayer feels much smoother — camera, controls, and effects (vignettes, particles, music swells) all stay in sync now instead of glitching during rollback.
  • Hundreds of fixes — actually hundreds. Screen flashes, object juice, screenshots, voxel marks, query ordering, and a long tail of small things that used to glitch now just work.
  • New animation stuff! Savi can now tween any property on any object — bounce a chest open, pulse a crystal, fade things in, flash red on hit. More animation features coming soon.

technical notes

  • Added channel-based animation mixer to ObjectAPI. Declarative setup via api.setProperty("mixer", { <channelName>: { clip, weight?, duration?, speed?, loop?, blendIn?, direction?, mask? } }). Runtime control via api.updateChannel(name, opts) (pass null to clear) and api.getChannel(name) returning { clip, weight, elapsed, duration, finished }. Channels blend by weight with optional per-bone masks ({ from: <bone> } or { bones: [...] }). Backed by new DrawMixer ECS component (replicate: AOI).
  • Added anime.js-style property tween API: api.animate(targetEntityId, { keyframes, duration, easing, delay, direction, loop }) and api.isAnimating(targetEntityId, dotPath?). Keyframes target dot-paths like "feetPosition.y", "material.emissive", "scale". Supports scalar tween values, value arrays ([a, b, c] evenly distributed), per-segment timing ([{ value, duration, easing }]), and relative deltas ("+=5" / "-=5"). Server-authoritative via new tween-evaluator system (order 105, after behavior-update); state stored in TweenState component (replicate: never).
  • Extracted shared easing.ts (easing curves + OKLCH color interpolation) used by both behavior builtins and the tween evaluator.
  • Added parseMixerChannel shared validator used by both the mixer property setter and updateChannel runtime call.
  • Added interpolation property (getter/setter) backed by DrawInterpolation; { teleportThreshold: number } or null to disable.
  • Note: the previously documented animated3DCharacter.action: { clip } shorthand never had a real implementation (the setter silently dropped the field). Use the mixer instead — declare locomotion + action as separate channels.
  • Camera authority split: render worker is now sole owner of yaw/pitch, integrating mouse deltas directly and publishing back to sim via a new CameraAngles SharedArrayBuffer channel. Spring-arm smoothing and physics-collision raycast moved from renderer into camera-behavior (sim), collapsing five per-frame orbit params into a single orbitDist the renderer lerps toward. Removes input-lag and stale WASD movement axes.
  • Renderer now also publishes its final pos+quat through the camera-angles SAB; sim consumes rendererTransform inside buildPointerRay() so click rays match what's on screen at >60 Hz refresh. View-state pos/rot intentionally left on sim-frame semantics so script-side camPos* is unchanged. Unit-norm guard rejects zero-initialized SAB state.
  • Compiled behavior scripts: Math.random() now delegates to a seeded RNG when one is installed (globalThis.__tomeSeededRng), making prediction/resimulation deterministic. New setTomeRng / clearTomeRng helpers in tome/resources.ts install the RNG into both the ECS resource and the global bridge consumed by compiler.ts's deterministic-Math injection. Wired through interpreter.ts, input-applier.ts, and behavior-update.ts.
  • TweenState now replicates with AOI + snap correction so animation tweens stay in sync across clients during rollback and resimulation.
  • ECS event component system restored (reverts the entity-based event experiment): queueEventAdd / drainEventAdds / injectTransient re-instated on the ctrl channel. Particle bursts (ParticlesBurstEvent) drained per tick and forwarded through the SAB render channel. Juice/audio dedup logic survives rollback.
  • Snap-frame application bypasses the prediction entity filter for replicate:"owner" components. Server-authoritative owner state (e.g. TomePlayerJuiceState) was previously dropped on predicted entities, breaking vignette, letterbox, music, and other effects in multiplayer.
  • ECS query-utils: query results are now sorted on every path (not only the index path) so behavior scripts iterating queries see a stable order frame-to-frame and across server/client.
  • New client-only DrawVisibilityOverride (replicate: never) lets camera-behavior hide the local player without clobbering the server's DrawVisibility. All render_v2 states honor overrideLayerMask with fallback to server layerMask.
  • SkipReplication removed from terrain streaming. Terrain entities now replicate normally and are protected from server despawn; event components on the client are fixed.
  • Camera API (tome/api/camera-api.ts) getProperty/setProperty/lookAt/setRotation read and write FeetPosition + Rotation directly instead of going through the now-removed InterpTransform blob. Script-visible shape and semantics are unchanged.
  • Replaced legacy renderer with render_v2: new RendererFeatureV2 (engine/render_v2/feature.ts) is the sole renderer wired by engine/client/engine-bootstrap.ts. Worker-side ECS sync, camera, smoothing, and decorations all flow through engine/render_v2/* instead of the deleted engine/render/main, engine/render/extractors, engine/render/commands, and engine/render/worker/worker-prep.ts paths.
  • Removed legacy render pipeline: RendererFeature, worker-renderer.ts, worker-prep.ts, render-worker-state-adapter.ts, transform-smoother.ts, the prep/extractor/command system, and ~75k lines of supporting tests/utilities are gone.
  • New SAB-backed RenderChannel (render_v2/render-channel.ts) replaces postMessage ECS sync. Adds a string table with generation-based eviction, u32 frame-header numOps, local-buffer overflow instead of dropping ops, growable arena/string-table SABs, and per-frame perf metrics + transport instrumentation.
  • Renderer now consumes ops directly — legacy intent conversion deleted. AppearanceIntentValue/LightIntent are gone; per-property Draw* writes (DrawModel, DrawSprite, DrawText, DrawSign, DrawMaterialOverrides, DrawAnimation, DrawVisibilityOverride, etc.) are written directly by the interpreter and replicated as their own components.
  • IndirectBatchedMesh primitive batching for cubes/spheres/etc., with shadow fixes: per-instance shadow override materials, follow-camera shadow frustum, cast/receive flags honored on model meshes.
  • Troika text vendored in-tree under render_v2/text/vendor/*. Forces the bundled Geist Pixel atlas, short-circuits FontResolver.resolveFallbacks, and drops the per-spec data.font path so the renderer worker never races on the troika unicode CDN. Outline/highlight bleeding fixed.
  • Screenshot capture rewired for v2: captureScreenshot is now exposed on RendererV2Handle and threaded back to Savi's view_game_canvas_screenshot tool; fixes a freeze and reprojects the selection beam to the crosshair on the render worker.
  • Renderer is now sole authority for camera yaw/pitch; spring-arm collision moved to sim, rendered transform sent back to sim each frame. New camera-smoother.ts, orientation.ts, and renderer-camera.ts under render_v2/camera/.
  • Entity smoother batches pos/rot ingest per tick, adds per-object interpolation component and flash effects. Transform/layout-scale now applied in render_v2 model and scene state.
  • ECS-driven outlines and selection visuals moved into render_v2. Skinned models, LOD, decorations, and water materials added. Voxel terrain pipeline rewritten on render_v2.
  • New sprite-node-material.ts, EnvironmentCore, FogCore, LightsCore, PostProcessingCore, RendererPipeline, RendererAnimation, and terrain-decoration-service.ts under render_v2/.
  • New run_ui_script Savi tool plus general client-RPC system (cf-studio-chat DO ↔ kiln ↔ iframe). Scoped to the active user's most-recent WebSocket; rejects responses from other tabs/connections.
  • view_game_canvas_screenshot now works under render_v2: wired captureScreenshot through the worker RPC, captures inside the render frame (WebGPU texture lifetime), bumped size limit (512KB→2MB) and timeout (500ms→2s). Switched final encode from transferToImageBitmap to convertToBlob so taking a screenshot no longer freezes the renderer.
  • Trimmed run_script and run_ui_script tool descriptions; removed duplicated API docs and don't-lists in favor of taste/footgun notes.
  • run_script result shape collapsed to { newVersion, return, logs? } / { ok: false, error, logs? }; surfaces all log levels with data args.
  • New wisp tools terminate_wisp and list_active_wisps (in wisp.ts, no longer monkey-patched in server.ts); 4-char hex wisp IDs; terminated wisps render as "Wisp terminated" in the footer.
  • Misc Savi-side polish: grep merges adjacent matches; inspect_versions summary-only mode; get_game_pulse resolves userId at invocation; str_replace_editor view shows line numbers; debug gizmo renders text tool results as pre-wrapped text; interpreter preserves TomeTerrainAnchor on spec-driven position updates.
  • Screen flash routed through DOM UI sink/transport (was ECS-only, broken in worker).
  • Object-motion juice now restores base position/scale on effect end.
  • Voxel structure mark bounds derived from template build() output.
  • SPAWN_AGENT.md is now the single source of truth for CLAUDE.md / AGENTS.md; generated by scripts/generate-agent-docs.ts. Stale AGENTS/engine/{component-mixin-timing,live-reload}.md removed.
  • Rewrote terrain streaming as non-replicated, client-driven. Chunk components flipped from replicate: "aoi" to replicate: "never"; clients now build their own chunks from the terrain definition instead of waiting on snap frames. Removes terrain entities from the replication budget entirely.
  • Split the monolithic terrain/systems.ts (~4800 LOC) into server-terrain-system.ts, client-terrain-system.ts, streaming.ts, and terrain-systems-shared.ts. Server runs request/ingest/streaming/rescue; client runs its own streaming + ingest + collider flush + mark-liquid + rescue.
  • Added voxel terrain pipeline: per-layer textures, packed layer-texture sharing across LODs, vertex colors, async texture loading, and AO. New terrain-tile-service.ts (render_v2) is the LOD/tile authority.
  • LOD transitions no longer use dithered noise — replaced with stable cross-fade so seams stop crawling at distance.
  • Voxel chunk builds: numeric greedy meshing, boundary cache, SharedArrayBuffer result slots for zero-copy worker→main transfer, deadline raised to 2000ms, deadline-failure recovery, and stale-voxel-lookup fix.
  • Server voxel builds capped to a 3×3 chunk authority window with retry backoff so cold starts and large worlds don't stall the tick.
  • Unified chunk entity ID prefixes under terrain/stream/…; cross-prefix despawn fix prevents leaked chunk entities.
  • 2D top-down places now skip terrain mesh/collider work entirely (heightmap startup gate + ocean shoreline restore).
  • Material rebuild thrashing eliminated; weight-texture checkerboard artifact fixed; sphere terrain positioning corrected; stale colliders invalidated with fallback anchors.
  • Removed SkipReplication component; terrain entities now protected from generic despawn paths instead.
  • F3 Advanced panel wired through worker-host with chunk-mesh lifecycle tracking.
  • Fixed _f32 ReferenceError when terrain generator scripts use float literals.
  • Script-facing API (api.getTerrainHeight, getTerrainNormal, getTerrainMaterial, getVoxelMaterial, isVoxelSolid, raycastVoxel, setVoxel, setVoxelState) is unchanged.
  • Internal: split monolithic AppearanceIntent ECS component into per-aspect Draw* components (DrawModel, DrawPrimitive, DrawSprite, DrawText, DrawSign, DrawMaterial, DrawVisibility, DrawInterpolation, DrawLight, DrawAnimated3DCharacter, TomeLayout). Public ObjectAPI.getProperty / setProperty keys (visible, model, primitive, material, sprite, text, sign, layout, animated3DCharacter, light) and their value shapes are preserved — scripts read and write the same things they did before.
  • New interpolation object property: { teleportThreshold: number } | null (or false to disable). Controls per-entity render-tick smoothing; sets DrawInterpolation.
  • New GameSpec.assets.metadata field (Record<string, AssetMetadata>) — engine-managed cache of CDN model bounds. New AssetMetadata type and patchAssets ScriptMutation kind for engine-internal writers.
  • Camera API (createCameraAPI) reads/writes FeetPosition + Rotation directly instead of InterpTransform. Public getProperty("feetPosition" | "rotation"), setProperty, lookAt, getControlTarget, and query shapes unchanged.
  • queryWorld now returns results sorted by entity id on both the index and full-scan paths (previously only the index path sorted). Iteration order in scripts that loop api.query() is now deterministic across the two paths.
  • Type re-anchoring (no runtime change, no spec-shape change): MaterialOverridesSpec now derives from engine DrawMaterialOverrides; SignSpec and ObjectProperties.text now derive from AppearanceSignValue / AppearanceTextValue; ObjectProperties.model is spelled string | { id: string; animation?: DrawAnimationValue }; ObjectProperties.sprite is spelled inline with the same fields.
  • Schema cleanup: removed unused exports RectangleSplineShapeSchema (use SplineShapeSchema) and TerrainMarkSchema (use HeightmapTerrainMarkSchema). Voxel structure mark bounds is now optional and auto-derived from build() output when a generator is supplied.

Genesis: engine v0.1.0

Released April 29, 2026

Welcome to engine versioning! Your game now tracks which engine version it runs on. You can see updates and choose when to upgrade.

technical notes

Initial versioned engine release. All prior builds are consolidated into v0.1.0.