Spawn

spawn / s

what we're building

everything we ship, and the reasons.

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

1 weekread →
v5.0.15Multiple moving shadow-casting lights now all keep their shadows — previously only one moving light at a time could.1 dayv5.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.1 dayv5.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.1 dayv5.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.1 dayv5.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.2 daysv5.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.2 daysv5.0.93D models are now automatically optimized for better performance, with additional simpler versions used as they move farther away.4 daysv5.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.5 daysv5.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.6 daysv5.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.6 daysv5.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.6 daysv5.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.6 daysv5.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.1 weekv5.0.2The editor cursor finally feels like your real mouse — pixel-for-pixel, no more laggy ghost trailing your hand in god mode1 weekv5.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.1 week

engine v4.6

Atelier

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

1 weekread →

engine v4.5

Surface Tension

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

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

engine v4.4

Solid

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

4 weeksread →
v4.4.3Fixed some characters appearing too small.3 weeksv4.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.3 weeksv4.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.4 weeks

engine v4.3

Groovy

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

May 13, 2026read →
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.May 13, 2026

engine v4.2

Continuum

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

May 9, 2026read →

engine v4.1

Foundations

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

May 4, 2026read →

engine v0.1

Genesis

Initial versioned engine release.

April 29, 2026read →

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.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 · breaking changes

  • 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 · breaking changes

  • 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 · breaking changes

  • 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 · breaking changes

  • 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 · breaking changes

  • 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 · breaking changes

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.