Stress test · field report · 6–8 Oct 2026 · v33
Final edition: project paused 2026-10-08

Where Virtual Matter Breaks

Over about two days and 21 build rounds I built a full multiplayer survival game, Ashlight, on a live Virtual Matter world, published it to a play server, and deliberately pushed the engine until it failed. This report gives the Who, What, When, Where and Why for each breakage, plus impact, a repro and a suggested fix. Every number comes from the live server or the connected client.

Framing 9b9ILBOjInycFrZ0KCFn1 Host voxelhost012 Client Native, Windows, iteration073 Tooling virtualmatter CLI + MCP Server idle baseline 59.5–59.8 fps Play server Europe, published Next edition SCAR (destruction FPS), final edition

Executive summary

What was tested. Ashlight is a multiplayer survival game built on a live Virtual Matter world (engine iteration073) over about two days and 21 build rounds: a 160 m persistent voxel world with journaled terrain edits, up to 24 server-driven rigged enemies, four bosses and a mini-boss, a server-wide siege, a custom full-screen light pass and a complete NewUI interface. The maker playtested it three times, once on a published play server. Along the way the build recorded 61 breakages (7 Critical, 17 High, 19 Medium, 18 Low), each with a 5W card, measurements, a mitigation and a suggested fix.

Top five takeaways for the renderer and server-threading refactor:

  1. The server renders a headless client every frame: about 6.2 ms of render plus 1.7 ms of client scene update, which caps an idle server near 58 fps (B43). Rendering it only on demand would return about 8 ms per frame, more than the game's entire Lua cost after optimisation.
  2. Main-thread stalls freeze every player: saving the terrain takes 33 s to about 2 minutes (B19), each voxel asset deep-copy 35–95 ms per rig part (B29), each asset bind 40–360 ms for small assets (B42), and creating 200 objects in one step 1.7 s (B2). Asynchronous save, copy and bind with completion callbacks would remove the longest freezes.
  3. Fixed per-object cost: about 21 µs per active voxel object per server frame (B17). With the editor session's own overhead, about 4 ms of a 60 fps frame was left for the game; idle SDK scripts add about 3 ms more (B38). This sets world density.
  4. Voxel ops on scaled objects, and restarts with no reason: an edit on a rig part at scale ≈0.19 touches about 140 times the voxels of the same edit at scale 1 (at least 6.6 ms of engine commit per op, typically 7–30 ms). Sustained stamping of that kind coincided with four of the seven unexplained server restarts (B31, B44), and no restart reason was visible to the builder.
  5. The client frame: on a native client, shadows took 11.8 ms of a 41.6 ms frame at shadow quality 5, and the SDK's particle updater, written in Lua, about 50 µs per live particle: 11.5 ms at 219 particles (B9).

The section "For the engine team" expands these with tables and requests. The closing section, "Final autopsy", gives the builder's view of the CLI and MCP tooling.

Summary

Building and stress-testing Ashlight surfaced 61 breakages: 7 Critical, 17 High, 19 Medium and 18 Low. They fall into four themes:

Status: the project was paused on 2026-10-08, with the game live on the published play server. The closing section, "Final autopsy", is the builder's view of the CLI and MCP tooling.

For the engine team: renderer and server-threading refactor

What this project measured that matters to a renderer rewrite and more server-side threading for voxel ops. Ashlight is a 160 m multiplayer survival world with up to 24 rigged enemies, four bosses and a mini-boss, a siege, persistent terrain edits, a custom light pass and a full NewUI HUD. It ran on iteration073 for about two days, and every number below was measured on that build; each item links to its 5W card. The question here is narrow: where did the frame go, what blocked the main thread, and what would more threading or a new renderer buy a real game?

1. The server frame budget, as a real game saw it

CostMeasuredCard
Headless client rendered by the server every frameAbout 6.2 ms render plus 1.7 ms client scene; an idle server tops out near 58 fpsB43
Fixed cost per active voxel objectAbout 21 µs per object per frame. An idle edit session already used 11.4–12 ms, leaving about 4 ms for the game at 60 fpsB17
Idle SDK and editor scripts3.0 ms per frame on an empty serverB38
SDK character controller per NPCAbout 0.3 ms; 40 rigged NPCs take a 60 fps server to 13 fpsB1
Moving voxel objects combined with raycasts15–27 frames of 50–245 ms in every 8 s with 24 moving rigsB16
Game Lua after optimisation2.5–4.2 ms per frame with 19–23 enemies (inactive components driven by one manager)What held up
Worst-case live siege (19–22 enemies, the Matron, the Colossus and a siege wave)About 55–56 fps, p99 29–30 ms, worst frame 32–37 ms, once spawn-time stalls were removedB29, Optimization result

Takeaway: at idle, the headless render client (B43) is the single largest fixed cost. Making it optional, or rendering it only when a screenshot is requested, returns about 8 ms per frame: more than all of the game's Lua combined.

2. Main-thread stalls: the best targets for a job system

Every one of these froze all connected players, not just the caller.

StallMeasuredWhat the game had to doCard
VoxelData:Save() on the terrain33–60+ s for the 250 MB map; a later full save of the larger terrain blocked run-lua for about 2 minutesSave only when the server is empty; journal edits in betweenB19
Asset deep-copy when a voxel object is createdAbout 35–95 ms per rig part (about 320 ms per 5-part rig). The spawn ramp's worst frame fell from 893 to 73 ms once rigs were pooled, recycled and their detail bakedA pre-built pool (6, later 14 rigs); no rig builds mid-fightB29
Asset bind (vd.asset = …)40–360 ms per bind for small assets (154–7,827 voxels), largely regardless of size; 1.2–1.4 s for a sparse 89k-voxel assetNever bind during playB42
Mass object creation+200 objects in one step: a 1.7 s freeze, 9.7 fpsSpread creation over framesB2
Voxel edits on scaled (≈0.19) rig partsAbout 140× the voxels of the same edit at scale 1; at least 6.6 ms of engine commit per op, typically 7–30 ms. Sustained stamping coincided with 4 of the 7 unexplained restartsA budget of 4 rig ops per second, none on a rig within 80 m of a playerB31, B44
Lua reload after any script saveEvery runtime-built object is destroyed and rebuilt: 10–30 s of stutterDeploys batched and made with nobody online where possible (re-adopting a pool after reload isn't possible)B32, B8

Requests for the refactor:

  1. Asynchronous save and bind, with completion callbacks. Snapshot the voxel data and write it on a worker; do the same for asset deep-copy and bind. These were the main causes of long freezes.
  2. Voxel ops on scaled objects in the object's own voxel space, at the object's resolution rather than world resolution, or with a cost estimate that accounts for scale. Scaled-part edits were both expensive and the leading suspect for the restarts.
  3. A cost guard that sees scale and same-frame sums (B13), and reports empty results (B37). Today a zero-volume op succeeds silently, and a left-handed parallelepiped builds nothing without an error (B36).
  4. Ordering guarantees for terrain edits under threading. Clients already keep terrain the server removed (B28) and lose static collision after a remove-then-add in one region (B3). Applying ops in parallel will make that harder unless each chunk carries an edit sequence or version.

3. The client frame, measured on a real native client

Hub scene with about 14 enemies loaded, timers from AE:GetLogTimers:

CostMeasured
Whole frame41.6 ms (about 24 fps) at shadow quality 5
Shadows (VPuzzleObject::Render - Shadows)11.8 ms at quality 5; 5.9–6.6 ms at quality 2. In combat, dropping graphics to Low (shadows off) took the client from 22.6 to 31.9 fps
Client Lua (engine profile)10.6–17.2 ms; client scene update about 12–17 ms
SDK particle updater, written in LuaAbout 50 µs per live particle: 11.5 ms at 219 particles
UI renderAbout 3.5 ms (the game's UI Lua 1.2–1.7 ms)
After the game's fixes (graphics tiers, particle caps, animation LOD)37.9 fps idle at the hub (from 19.6); 33–37 fps in a fight with a husk group and a hound pack (from 22.6)

Requests:

  1. Shadows are the largest single render cost a game controls. Shadow LOD or cascades that scale with distance and object count would help most. Exposing per-pass timers as a documented API would help too; they were how this was found.
  2. A native particle system with enforced caps. The Lua updater cost more than all of the game's client Lua, and the documented 500 cap isn't enforced: 8,000 particles gave 12.7 fps (B9).
  3. Client-side asset instancing (B18). Clients can't spawn voxel assets locally, so every cosmetic prop and rig part is a server object that pays B17's per-object cost and streaming. Client-only cosmetics would shift real load off the server.
  4. LOD prioritisation that doesn't starve the scene (B10) past about 48 objects with prioritizeLod, and a lit, full-LOD path for automated screenshots (B26).

4. Networking and state findings relevant to the refactor

5. Stability: the unexplained restarts

The server restarted seven times in about 15 hours (B44), once on the published play server in the middle of a boss fight. No exit reason, log or warning was visible to the builder. Four of the seven came during live voxel edits on scaled rig parts. That includes the mid-fight restart, when per-hit paints on scaled parts were running and the Gate-Warden's rebuild (68 stamping jobs onto scale-2 parts) could stamp near connected players. After those ops were budgeted to near zero, no further unexplained restarts were reported in the time left, though that window was short.

Request: surface the restart reason (exit code, out-of-memory, watchdog or assert) and the last log lines to the maker and the CLI. With more threading, crash diagnosability matters even more.

6. Ranked: what the refactor could do for games like this

  1. Make the server's headless render client optional: about 8 ms per frame back.
  2. Asynchronous terrain save, asset bind and deep-copy: removes freezes from tens of milliseconds up to about 2 minutes.
  3. A cheaper, threaded per-object update: about 21 µs per object today, which limits world density.
  4. Voxel ops on scaled objects in local space, with a scale-aware cost guard, and possibly fewer crashes.
  5. A native particle system with enforced caps, and shadow LOD on the client.
  6. Client-side asset instancing for cosmetics.
  7. Versioned terrain edits per chunk, so threaded edits stay consistent between client and server.
  8. Crash reasons and logs exposed to builders.

Breakage index

#SeverityBreakageBreak point
B1CriticalServer NPC capacity40 NPCs → 13.2 fps
B2CriticalDynamic object creation spike+200 in one step → 9.7 fps
B3CriticalClient loses static collisionRemove then Add in one region
B4CriticalSync overwrites a newer local save2 saves ~5 s apart
B5HighUncapped networked payload256 MB → 3.57 s ping
B6HighUncapped Lua heap3 GB, main thread blocked
B7Highimagine() silent stalls>5 concurrent jobs
B8HighHot reload orphans live workAny .lua save; 0.39 fps
B9MediumClient particle cap not enforced8,000 → 12.7 fps
B10MediumScene-wide LOD starvation~48 prioritizeLod objects
B11MediumTransforms that don't replicateNew child scale, plain pos
B12MediumOpaque persisted tablesConfig, varsTable
B13MediumEdit guard estimatesThin cylinders, same-frame sums
B14LowEditor Tools boot errorsEvery boot
B15LowDocs vs runtime mismatches4 confirmed
B16CriticalMoving voxel objects + raycasts spike the server50–245 ms frames
B17HighFixed per-object cost and thin headroom~21 µs/object, ~4 ms spare
B18MediumClients can't instance assetsNo client-side rig parts
B19CriticalSaving the terrain freezes the server33–60+ s for 250 MB
B20HighVoxel edits aren't persistent by defaultEvery gameplay edit; procedural props
B21HighSync overwrite race, again2 saves <10 s apart
B22MediumHidden browser tabs throttle the client6–12 fps
B23HighPolled input drops fast tapsPress+release in one frame
B24MediumGenerated audio reaches clients slowly7 of 14 after ~3 min
B25LowPlatform popup covers the game's HUDBrowser clients
B26MediumScreenshot tool can't show final visualsLow LOD, no custom lighting
B27HighTime.time restarts on every Lua reloadEvery hot reload
B28CriticalClients keep terrain the server has removedStatic removes 2–100 m from the camera
B29HighEvery voxel object deep-copies its asset on the main thread35–95 ms per part; ramp worst 893 → 73 ms
B30MediumThe SDK character controller jumps 2.5 m and has no slope limitWalls under ~2.6 m are climbable
B31MediumScaled rig parts: forced rays miss them and their units don't match the worldPart scale 0.19; forced ray hit nothing
B32MediumEvery Lua reload destroys runtime-built objects10–30 s of stutter per deploy
B33LowNo per-object time scale, no UI gradientsHit-stop and vignettes faked
B34LowNew scripts can't be attached for about a minute"Script file does not exist"
B35MediumA coding harness can't run automated playtests~1 in 3 calls "fetch failed"; fly-cam client
B36HighLeft-handed parallelepipeds silently build nothing327 facets, every home wall; SDK Explosion.lua
B37MediumVoxel ops never report an empty resultAny degenerate shape
B38LowSDK editor scripts cost ~3 ms per server frame when idle3.0 ms vs <0.6 ms for the game
B39LowServer memory climbs on an idle, empty serverAtlas capped at 5,000 MB; +7 MB/min after
B40LowInactive objects ignore forced raycastsForced ray returns nil
B41HighAE:GetDebugTime() restarts with the server processEvery server restart
B42MediumBaked rig variants are huge; a periodic freeze came with them329 MB for 111 variants; any bind 40–360 ms
B43MediumThe server renders a headless client every frame~8 ms per frame; ~58 fps at idle
B44HighThe server restarts without warning or a reason7 restarts; one mid-boss-fight on the play server
B45HighAfter a server restart, sync silently fails every pushHTTP 403 on every push; 3 deploys lost
B46HighServer Lua has no stable per-user id7 of 7 records were placeholders
B47Mediumtype() of a Vec3 returns "Vec3", not "userdata"Every guarded vector dropped
B48Highvirtualmatter sync leaves orphans and pushes things it shouldn't3 syncs at once; 370 MB pulls; 2 Lua resets
B49MediumA force-killed client stays connected for 1–2 minutesGhost in GetClients() ~1–2 min
B50LowClient transform race on NPCsNPC drawn ~5× too big
B51HighPublishing can boot the wrong scene, then save over the published oneRestart before 390 MB terrain lands
B52MediumPlay servers boot a different scene from the editor'sInitScene vs PlayScene, no warning
B53LowGenerated asset names are normalised"v2" → "V2", "V2L" → "V2l"
B54LowText-to-music sometimes returns a 5–15 s clip2 of 8 beds regenerated
B55LowNetworked tables adopt the tables they're given"table can not have metatable"
B56LowMUI panels don't wrap by default, though the docs say they dooverflow defaults to "Hidden"
B57LowWidget.sizing is write-onlyReading it raises
B58LowA disabled button is illegibleBlue-grey tint, near-invisible text
B59LowA clear background with a frame renders as a grey fillTransparent + border
B60Lowstring.upper corrupted a multi-byte character"—" mangled
B61LowGenerated icon art is unreadable below about 24 px39 generated icons

Severity reflects player impact in a shared world. "Measured" means I isolated the cause with a benchmark or profiler. "Inferred" means the symptom is confirmed but the cause is my best reading from outside the engine.

The 5Ws, per breakage

B1

Server NPC capacity tops out near 40

CriticalWhy: measured↑ index
Who
Every player on a world that runs server-side enemies or companions. It hits any maker who uses the SDK's Examples/Character Controller.lua for NPCs, as the skills docs recommend.
What
Server fps fell from 59.8 to 41.5 at 10 rigged NPCs, 30.4 at 20 and 13.2 at 40. Spawning 20 at once blocked the server for 8.2 s in a single frame. Spawned one at a time, the worst single spawn still took 675 ms.
When
Steady state, not just at spawn. Forty NPCs held 13.2 fps for as long as they lived.
Where
Character Controller:updateControls() on the server: its capsule overlap via Collision GetOverlap. Spawn cost comes from creating about 11 streamed objects per segmented rig.
Why
A benchmark put one updateControls call at 299 µs. Forty NPCs call it every frame, which is 12 ms of a 16.7 ms budget before any AI runs. The profiler showed the NPC script at 28.6 ms per frame for 40 instances.

Impact

A world can't hold the 30–60 server-driven enemies a survival or MMO-style game expects. I capped Ashlight at 24 and throttled distant NPCs to 5 Hz movement. That lifted 40 NPCs to 27.1 fps and 24 NPCs to 36.2 fps.

Repro

-- spawn N CC characters, then:
Scene:SetProfileScriptsUpdate(true)
local t0 = AE:GetDebugTime()
for i=1,50 do cc:updateControls(Vec3(0.1,0,0), false) end
return (AE:GetDebugTime()-t0)*1e6/50  -- ≈299 µs

Suggested fix

Move the server capsule sweep into native code, or batch it across characters per frame. Add a built-in movement LOD so characters far from every client step at a lower rate. Document a per-NPC budget in the character-controller skill.

B2

Creating voxel objects stalls the whole server

CriticalWhy: inferred↑ index
Who
All connected players during any burst of object creation: spawning waves, building, debris, rig assembly.
What
I added objects (one VoxelData plus a 0.3 m sphere each) in steps. Going from 200 to 400 in one step took 1,658 ms and dropped the server to 9.7 fps average and 0.2 fps minimum. Even the 100-object steps hit a 0.5 fps minimum. Afterwards, steady-state cost also grows with count: +300 objects ran at 54.8 fps and +1,000 at 36.9 fps.
When
At creation (the spike), then in proportion to live object count.
Where
Scene:CreateObject + AddComponent("VoxelData") + the first Vox:Add(...):Force(obj), plus per-object streaming to clients.
Why
Creation work appears to run synchronously on the main thread with no per-frame budget. The edit guard throttles voxel volume but not object count. The steady-state cost points to per-object streaming and transform bookkeeping.

Impact

Segmented characters multiply the problem: each Ashlight rig is about 11 objects, so 40 enemies means about 440 objects. Destruction that spawns physical rubble runs into the same wall.

Repro

for i=1,200 do
  local o = Scene:CreateObject("S"..i)
  o.pos = Vec3(i%40, 2, i//40)
  o:AddComponent("VoxelData").syncToClients = true
  Vox:Add(Sphere(o.pos,0.3)):Force(o):Run()
end  -- watch Time.fps for 4 s

Suggested fix

Queue object creation behind a per-frame budget, as the edit guard already does for voxel volume, or warn when one frame creates more than N objects. A lightweight "rig part" object type that shares one stream would make segmented characters affordable.

B3

A client loses collision the server still has

CriticalWhy: inferred↑ index
Who
A connected player standing on terrain that the server rebuilds while they are there.
What
The player fell through a floor that existed on the server. The client's collision and raycasts missed static voxels that the server reported as present.
When
After a large async Vox:Remove (box quarters), followed within a few frames by Vox:Add in the same region. Building in fresh regions never triggered it.
Where
Client static voxel streaming and collision (VVCollision). The client-side character controller collides against client voxel data only.
Why
The remove and add deltas likely arrive or apply out of order, or the add is dropped while the remove is still streaming. None of AE:ReloadGeometry(), AE:AEVV_InvalidateData() or Client:ReloadPage(false) repaired it. ReloadPage did nothing at all in the native client.

Impact

Any destructible or rebuildable world, which is the platform's headline feature, can drop players into the void. I worked around it with settle waits after clears and a fall-catch that teleports players back.

Repro

Vox:Remove(Box(c, Vec3(16,4,16))):ForceStatic():Run()
util:schedule(0.05, function()
  Vox:Add(Cylinder(c, c+Vec3(0,1,0), 7)):ForceStatic():Run()
end)  -- stand a client on c

Suggested fix

Order or version static deltas per chunk so a client never applies an older remove over a newer add. Expose a working "resync region" call. Make ReloadPage work in the native client, or report an error when it can't.

B4

Sync overwrites a newer local save

CriticalWhy: measured↑ index
Who
Makers and coding agents editing through npx virtualmatter sync.
What
I saved Hollow.lua twice, about 5 s apart. The log shows pushed Hollow.lua and then a run of pulled Hollow.lua. The second local edit was gone from disk, with no conflict file and no warning. Separately, a stopped background sync left its node child running; two syncs then produced .remote-conflict files and pulled a 157 MB .am~ temp file into the mirror.
When
When a local save lands while the hot-reload echo of the previous push is still being pulled back.
Where
The CLI's two-way sync: the echo of its own push is treated as a remote change.
Why
The pull compares the remote copy against stale state instead of the content hash it just pushed, so its own echo overwrites a newer local file.

Impact

This is silent loss of source code. An agent that saves a file twice in a row can believe a fix is live when it isn't. I only caught it because I grepped for the change.

Repro

npx virtualmatter sync &
echo "-- a" >> X.lua; sleep 5
echo "-- b" >> X.lua; sleep 20
tail -2 X.lua   # "-- b" is missing

Suggested fix

Track the hash of each push and ignore the matching echo. Never let a pull overwrite a file whose local mtime is newer than the last push; write a conflict file instead. Kill child processes on exit and exclude *.am~.

B5

Networked tables accept any payload size

HighWhy: measured↑ index
Who
Every client of a world whose script writes large values into a util:makeNetworkedTable field.
What
A 16 MB string was written in 94 ms. 64 MB and 256 MB strings were also accepted and delivered. At 256 MB the client's ping rose to 3.57 s, freezing input and replication for every player. It recovered once I cleared the field.
When
On a single write. There is no warning at any size.
Where
Networked table delta replication, server to client.
Why
There is no size cap and no chunking: a large value blocks the reliable channel that carries everything else.

Impact

One careless or hostile script, such as a chat log or inventory dump growing without bound, can lag every player in the session.

Repro

self.net.blob = string.rep("x", 256*1024*1024)
-- watch client ping

Suggested fix

Cap each field (for example 1 MB) and raise a Lua error above it. Send large values on a separate low-priority channel so they can't starve input and transform traffic.

B6

The server Lua heap has no ceiling

HighWhy: measured↑ index
Who
Whole sessions. A runaway script can starve the host process that every player shares.
What
The Lua heap grew to 3,072 MB with no limit, taking the process to 21.7 GB. A run-lua call that blocked the main thread ran past the 60 s CLI timeout. After freeing, Lua dropped to 9 MB, but the process stayed roughly 0.3 GB above its earlier footprint. Over the whole session, server memory rose from 6.1 GB to about 19 GB.
When
When a script allocates without bound, or a long synchronous loop runs inside run-lua.
Where
The server Lua VM allocator, and the run-lua bridge, which runs on the main thread.
Why
There is no allocator limit or instruction-count hook in the sandbox. The allocator keeps freed pages, which is normal, but nothing tells a maker how close the host is to its limit.

Impact

A single infinite-append bug can take the world down for everyone, and an agent's long query freezes the simulation while it runs.

Repro

local t = {}
while collectgarbage("count")/1024 < 3072 do
  t[#t+1] = string.rep("A", 1024*1024)
end

Suggested fix

Set a per-world Lua memory limit through lua_setallocf that raises a catchable error. Add an instruction-count hook so run-lua fails cleanly instead of hanging the main thread.

B7

imagine() jobs stall forever, silently

HighWhy: inferred↑ index
Who
Makers and agents generating assets.
What
Of 11 concurrent jobs, 5 finished quickly and 6 sat at 0% for over 20 minutes. Some prompts never progressed at all ("shadow creature … horror", "iron staff with hook"); reworded, they finished in about 75 s. No job ever fired an error, timeout or failure callback.
When
With more than 5 concurrent jobs, or with certain prompt content.
Where
The imagine() job queue and its generation backend.
Why
Probably a concurrency limit of 5 that drops or parks overflow jobs instead of queueing them. The prompt stalls look like a content filter rejecting the job without reporting back.

Impact

A preview blockout sits in the world indefinitely. Agents can't tell "slow" from "dead", so they wait or retry and burn credits.

Repro

for i=1,11 do
  imagine("a wooden barrel "..i, Scene:CreateObject("I"..i), {})
end  -- 6 never progress

Suggested fix

Queue extra jobs visibly with a status field, add a failure callback and a timeout, and return the reason when a prompt is rejected.

B8

Hot reload orphans everything in flight

HighWhy: measured↑ index
Who
Players in a world that is edited live, plus makers running long builds.
What
Saving any .lua file re-runs every script, and anything async is lost: Vox job queues, util:schedule chains and imagine() callbacks. With 40 NPCs alive, one save dropped the server to 0.39 fps for several seconds, and every NPC instance was orphaned.
When
On every save while sync is running, including saves to scripts unrelated to the running work.
Where
The hot reload path: a full Lua reset and re-instantiation of all scripts.
Why
Reload is global rather than per module, and callbacks keep references to the old instances.

Impact

Live-ops edits hitch every player and can break game state. I had to make the terrain builder resumable through Config and avoid saving during builds.

Repro

util:schedule(30, function() print("fired") end)
-- now save any .lua file
-- "fired" never prints

Suggested fix

Reload only the changed module, and remap or cancel orphaned callbacks with a warning. Provide a hook such as OnBeforeReload so scripts can hand off state.

B9

The documented particle cap isn't enforced

MediumWhy: measured↑ index
Who
Players on lower-end clients during heavy effects.
What
The docs say "max 500 particles". The client accepted 500, 2,000 and 8,000 in a single call. Client fps went from about 34–43 to 29.2 at 500, 26.2 at 2,000 (minimum 15.7) and 12.7 at 8,000 (minimum 5.4).

Update: a client profile during play showed the SDK's own Particle Updater script costing about 50 µs per live particle: 11.5 ms of the frame at 219 particles. Game-side caps (200, 140 or 80 by graphics tier, 40 for ambient effects) brought client frames from about 41.6 ms to 28–39 ms together with lower shadow quality.
When
Immediately, on the client that spawns them.
Where
Editor Tools/Particles.lua addParticle on the client.
Why
No cap or eviction exists, and the per-particle simulation runs in Lua.

Impact

Effects-heavy moments, such as explosions or a crowd of fire, drop clients into slideshow territory.

Repro

local ps = Scene:GetObjectByName("Particles"):FindScript("Particles")
for i=1,8000 do ps:addParticle(p, {lifetime={5,6}}) end

Suggested fix

Enforce the cap by evicting the oldest particles, and make it configurable. Long term, simulate particles natively or on the GPU.

B10

Scene-wide LOD starvation

MediumWhy: measured↑ index
Who
Every client in a scene with many prioritized dynamic objects.
What
About 48 objects with prioritizeLod = true (lodBias 5) collapsed the whole scene's LOD on the client into giant blocks, including the hub fire 7 m from the camera. Removing them restored detail. lodBias 3 per character, without the prioritize flag, was fine.
When
When the combined prioritized budget exceeds the client's stream budget.
Where
The client LOD and streaming budget allocator.
Why
The prioritized objects take the shared budget before the rest of the scene gets any.

Impact

Making characters look sharp silently ruins everything else on screen.

Repro

for each of 48 rig parts:
  vd.prioritizeLod = true; vd.lodBias = 5

Suggested fix

Cap the share of the budget that prioritized objects can claim, and keep a floor for static terrain near the camera.

B11

Transforms that don't replicate

MediumWhy: measured↑ index
Who
Makers building attached gear, props or rigs.
What
A child object created on the server with scale 0.089 showed up on clients at scale 1.0, producing a mace 11 times too big. Plain objects without a character controller showed up on clients at (0,0,0).
When
On creation, and on every later change.
Where
Object transform replication.
Why
Only character controllers replicate their position, through RPCs, and scale is never sent. The docs mention position, but the scale case catches people out.

Impact

Every maker ends up building their own transform sync.

Repro

local c = Scene:CreateObject("G"); c.parent = root
c.scale = 0.089   -- client reads 1.0

Suggested fix

Add an opt-in replicateTransform flag that covers position, rotation and scale, or at least send initial values at spawn.

B12

Persisted tables come back opaque

MediumWhy: measured↑ index
Who
Anyone persisting game state.
What
Config:TempSet(key, table) comes back as an opaque sol.LuaTable userdata that can't be indexed or iterated. component.varsTable can't be iterated with pairs, and vt.field returns nil for values set with setSerializeVar.
When
On read-back after a Lua reset.
Where
The Config store and the VarTable bindings.
Why
The bindings return the raw sol object rather than converting it back to a Lua table.

Impact

Silent data loss for anyone who doesn't test a round trip. I serialized everything into Lua-literal strings instead.

Repro

Config:TempSet("k", {a=1})
local t = Config:TempGet("k")
return type(t), t.a  -- userdata, error

Suggested fix

Deep-convert values on get, or reject tables on set with a clear error.

B13

Edit guard estimates are hard to predict

MediumWhy: measured↑ index
Who
Makers and agents building terrain or structures.
What
A thin cylinder (r 17, h 0.55) was estimated at about 3,195 baseline-m² and rejected, while a sphere of r 6.5 was accepted. A sphere of r 10 was rejected because about 505 m² had already been issued that frame. Single spheres passed up to r 8–11, were soft-rejected at r 12–17 and hard-refused at r 24 and above.
When
On Run().
Where
The Vox edit guard.
Why
Cylinder cost is estimated well above the documented formula, and the guard sums all ops in a frame. The sum is sensible, but the documentation doesn't say so.

Impact

Trial and error for anything larger than a room. The rejection message itself is good and actionable.

Repro

Vox:Add(Cylinder(c, c+Vec3(0,0.55,0), 17))
  :ForceStatic():Run()  -- rejected

Suggested fix

Expose Vox:EstimateCost(op), and document the per-shape cost and the same-frame aggregation.

B14

Editor Tools fail at boot

LowWhy: inferred↑ index
Who
Every world on this build.
What
Five errors on every boot: Editor.lua:63 events read-only (memoryPressure), Transform Gizmo.lua:260 onSnapChanged nil, Shape Manager.lua:220 onPermissionChanged nil, Editor.lua:785 onSnapChangedTrigger nil, Events.lua:742 triggerLocally nil.
When
On every Lua reset.
Where
The Editor Tools scripts against the engine's events module.
Why
Version skew between the Editor Tools scripts and the engine. One knock-on effect: the Shape Manager path that resumes imagine() jobs is broken.

Impact

Noise in the error channel that agents must filter, and a broken job-resume path.

Repro

Server:LuaReset()  -- then read errors

Suggested fix

Ship Editor Tools versioned to the engine, and add a CI boot test with zero errors.

B15

Docs and runtime disagree

LowWhy: measured↑ index
Who
Agents and makers who follow the skills docs.
What
Scene:CreateMaterial is documented and used by a skill, but nil at runtime. Button.onPress expects an event-name string; the function form is onPressEx. Generated models face −Z, while the skill says +Z. The particle cap of 500 is documented but not enforced (B9).
When
On first use.
Where
apiEmmyAtomontage.lua and the Skills/ references.
Why
The API dump and the runtime build have drifted apart.

Impact

Wasted turns, plus rigs and characters that face backwards.

Repro

return Scene.CreateMaterial  -- nil

Suggested fix

Generate the API dump from the running build, and add doc tests that call every documented symbol.

B16

Moving voxel objects plus raycasts spike the server

CriticalWhy: inferred↑ index
Who
Every player in a world with server-driven moving characters that also use server raycasts, which describes almost any game with AI.
What
With 24 rigged enemies (6 voxel objects each) moving on the server, 15–27 frames in every 8 s took 50–245 ms, and average fps fell to 40. The game was otherwise identical. Moving the objects without raycasts: 54.9 fps, no spikes. Raycasts without moving: 54.4 fps, no spikes. Both together: the spikes return.
When
When objects are moved through obj.pos at 1–15 Hz while scripts run about 240 static rays per second. The rays use ForceStatic, so they shouldn't touch dynamic objects at all.
Where
Spike frames show NEW/VPO Update Part 7 (80–128 ms), NEW/FixedUpdate VPuzzleObject and NEW/_AmStreaming - Sending (54–74 ms).
Why
Probably a deferred rebuild of the dynamic-object spatial or streaming structures, triggered when a query follows transform changes. An individual ray is only 0.03–1 ms, so the cost lands later in the frame.

Impact

This was the single biggest cause of stutter in the game. The workaround is unusual: keep each enemy's real position in Lua, replicate it through a networked field, and move the engine object only every 10 s or 8 m.

Repro

-- 24 roots with 5 child VoxelData each
-- every frame: root.pos += v*dt
-- 10 Hz each: Cast:Ray(p, down):ForceStatic():Run()
-- watch SUF/Update Frame for 8 s

Suggested fix

Make static-only queries skip dynamic-structure updates. Batch transform-driven rebuilds per frame with a time budget, and expose a "client-driven, server-static" flag for objects whose clients position them anyway (receiveTransform = false).

B17

Fixed per-object cost and very little headroom

HighWhy: measured↑ index
Who
Any world with animated characters built from several voxel parts.
What
Each active dynamic VoxelData costs about 21 µs per server frame, even when it doesn't move: 260 rig parts cost 5.4 ms. Inactive objects are effectively free, and switching off 22 hidden template roots saved about 1 ms. editable = false changes nothing. An idle edit session already uses 11.4–12 ms of update time: editor tools Lua is about 1.8 ms (the undo History script alone 0.9 ms), and the server's own screenshot camera adds about 2.6 ms of render and blit. That leaves roughly 4 ms for the game at 60 fps.
When
Constantly.
Where
Update VPuzzleObject and the edit session's Editor Tools.
Why
Per-object bookkeeping runs every frame regardless of state, and editor and screenshot work run even when nobody is using them.

Impact

I cut each enemy from 11 to 5 voxel objects (a lighter rig with virtual elbow and knee joints) to reach 53–56 fps with 24 enemies, up from 13 fps at the start. A solid 60 fps with 24 enemies grouped around one player isn't reachable on this host, because the idle engine overhead alone is about 12 ms.

Repro

-- 260 parked VoxelData objects:
-- SUF/Update Frame goes from 14.7 ms to 20.1 ms

Suggested fix

Skip unchanged objects in the per-frame update. Render the server's screenshot camera only on request. Profile and trim Editor Tools, or turn them off in play sessions.

B18

Clients can't instance voxel assets

MediumWhy: measured↑ index
Who
Makers who want animated characters without paying B17 on the server.
What
On the client, AE:GetAssets() returns none of the world's rig assets: 0 of the 150 the server lists. The client VoxelData.data is nil, and a template's .asset reads nil. A client can't create local copies of character parts, so every animated limb has to be a server object.
When
Always.
Where
Client asset sync and VoxelData bindings.
Why
Voxel content is streamed per server object instead of shipped as shared assets.

Impact

It blocks the obvious fix for B17: server keeps one root per character, and each client instances the limbs locally.

Repro

-- on a client
local t = Scene:GetObjectByName("Rig Hollow torso")
return t:GetComponentByType("VoxelData").asset -- nil

Suggested fix

Allow client-local VoxelData to reference a server-streamed object's content, or sync asset references so clients can instance them.

B19

Saving the terrain freezes the server for 33 seconds to over a minute

CriticalWhy: measured↑ index
Who
Everyone connected when a world saves its terrain.
What
VoxelData:Save() on the static terrain (a 160 m map at 32 vpm, a 250 MB .am) blocked the main thread past the CLI's 60 s timeout. The CLI returned HTTP 504 and the world was frozen until the save finished. Sync then pulled a 100–120 MB .am~ temp file into the local mirror.

Update: a QA frame sampler happened to straddle one of the Terrain Keeper's automatic saves on an empty server. It recorded a single 33,415 ms frame (10.3 fps averaged over its 8 s window), and every run-lua in that window returned HTTP 504. The same 250 MB map therefore stalls the main thread for anywhere from about 33 s to over 60 s. A later full save, of the larger terrain and with nobody online, blocked run-lua for about 2 minutes (HTTP 504s).
When
On every save of the static terrain. Server:SaveScene() also re-saves it.
Where
Static voxel serialization, which is synchronous and saves the whole file every time.
Why
The save is a single blocking write of the entire common collection, with no async, incremental or per-chunk dirty path.

Impact

A persistent survival world can't save its terrain while anyone plays. Most terrain changes are only safe once the server is empty.

Mitigation shipped

A Terrain Keeper module saves the full terrain only after the server has been empty for 45 s, at most every 10 minutes. Between saves, every gameplay edit is written to a compact journal that survives reloads and restarts, and is replayed onto the reverted terrain after a restart. The first automatic save ran while the server was empty. The Keeper now saves edits that the journal doesn't cover at most once an hour.

Suggested fix

Save only dirty chunks, asynchronously on a worker thread with a copy-on-write snapshot. Expose an edit log the engine replays itself.

B20

Gameplay voxel edits aren't persistent by default

HighWhy: measured↑ index
Who
Players who dig, salvage, build or destroy, which in a voxel game is everyone.
What
After hours of play the static terrain reported isSaved = false. Only the scene hierarchy had been saved every 2 minutes, which excludes voxels. A restart would revert salvaged walls, burned blight, broken ruins and dug holes. Systems that rebuild from saved game state (homes, blight, arena) would then re-add geometry on top of the reverted terrain. Players see walls that pop back up, and growth that stacks.

Update: dynamic objects lose procedural voxels too. After a server restart, all 12 oil drums (save = true, each built from four Vox:Add cylinders with Force(obj)) were back in the scene, active and with their VoxelData component, but with volume 0, zero bounds and no asset: invisible, with no error. The scene's dynamic-prop count went from 37 to 25.
When
On any server restart.
Where
The split between SaveSceneHierarchy (cheap, no voxels) and voxel saves (B19). It applies to static terrain and to procedurally built dynamic objects; only asset-backed VoxelData, or an explicit VoxelData:Save(), survives.
Why
The cheap save path is the documented default. The voxel save path is too expensive to run while anyone is playing.

Impact

"Persistent world" is the game's promise, and the engine's cheap save path quietly breaks it.

Mitigation shipped

Terrain Keeper journal (see B19). Harvesting, digging, blight burning and lean-to shelters all go through it. Rebuild-from-state systems use exact saved positions, so replaying them is idempotent. For procedural props, the fix is to rebuild on boot whenever the VoxelData is empty, not only when the object is missing, or to save the prop as an asset.

Suggested fix

Include voxel deltas in the hierarchy save (journal or dirty-chunk diffs), or warn makers when a world has gone more than N minutes with unsaved static edits.

B21

The sync overwrite race happened again

HighWhy: measured↑ index
Who
Anyone editing through npx virtualmatter sync, especially coding agents that save quickly.
What
Two quick edits to Terrain Keeper.lua: the second was partly lost when sync pulled the echo of the first back over it. One fix was missing from disk with no conflict file. Same mechanism as B4.
When
When two saves to one file land within about 10 s.
Where
CLI two-way sync.
Why
See B4: the pull treats the echo of its own push as a remote change.

Impact

Silent source loss mid-session, again.

Mitigation shipped

All edits are staged outside the mirror and copied in one write per file. I wait 25–30 s, then compare every file against its staged copy before testing. Parallel agents write only to staging folders, and one deployer copies.

Suggested fix

As in B4: track pushed hashes and never let a pull overwrite a newer local file.

B22

Hidden browser tabs throttle the client

MediumWhy: inferred↑ index
Who
Players with the game in a background browser tab, and anyone measuring client performance.
What
The connected WASM client measured 6–12 fps even with no enemies, against 34–43 earlier. The client stays connected while throttled, so its player keeps existing in the world.
When
When the tab or window is hidden.
Where
Browser timer and rAF throttling of the WASM client.
Why
Normal browser behaviour, but the server can't tell an AFK, throttled client from an active one.

Impact

Client-side numbers are only reliable from a focused window, and AFK players still pull enemies.

Mitigation

Client performance tuning is based on code-path costs rather than that client's fps.

Suggested fix

Report a client "visible / throttled" flag to the server (Client:IsAppVisible exists client-side) so games can treat AFK players differently.

B23

Polled input drops fast taps

HighWhy: measured↑ index
Who
Players on slow or throttled clients, and anyone using automation or accessibility tools that send quick taps.
What
The engine's events.keyDown listener received "F", but Input:KeyDown("F"), which the SDK's controllers poll, never returned true. The lantern didn't light.
When
When a key goes down and up within the same rendered frame. That is common at the 6–12 fps of a throttled browser client (B22).
Where
The Input polling API, which samples state per frame instead of edges.
Why
Press and release edges inside one frame cancel out before the poll.

Impact

Players on weak devices lose actions: dodges, flasks, interactions.

Mitigation shipped

The player script queues events.keyDown events and consumes them once per frame. A pressed(k) helper returns the poll OR the queued edge. Verified live: the lantern lit, and the tutorial advanced.

Suggested fix

Latch press edges in Input:KeyDown until the next Lua update, and document the event API as the default.

B24

Generated audio reaches clients slowly

MediumWhy: measured↑ index
Who
Players connected while new sounds or music are added.
What
Fal generated 14 of 14 clips without a failure: 11 sound effects of 20–240 KB, and 3 music tracks of 3.4–4.7 MB. A browser client that connected afterwards listed none of them at first, then 7 of 14 about 3 minutes later.
When
After a montage audio asset is created.
Where
Asset sync to clients (events.assetSyncedIn).
Why
Inferred: assets are streamed in the background at a modest rate, and big MP3s queue behind each other.

Impact

New players hear placeholder or no sounds for minutes.

Mitigation shipped

The audio module re-indexes on every assetSyncedIn event and falls back to similar built-in SDK sounds until the custom ones arrive.

Suggested fix

Prioritise small assets, expose sync progress, and allow preloading a declared asset list at connect.

B25

The platform's popup covers the game's HUD

LowWhy: measured↑ index
Who
Browser players.
What
The website-drawn "Running slow in this browser?" card (about 200×120 px at 1280×720, bottom-left) sits on top of the game's vitals panel until dismissed. Games can't see it or move it.
When
Shortly after joining in a browser.
Where
The make.virtualmatter.ai page chrome around the engine iframe.
Why
The page and the game don't share layout information.

Impact

Health and oil are hidden at the moment a new player most needs them.

Mitigation

A tunable lift for the vitals panel (AshUI.VITALS_LIFT) exists; the default keeps the layout clean.

Suggested fix

Place platform toasts in a reserved corner, or give games a safe-area query.

B26

The screenshot tool can't show final visuals

MediumWhy: measured↑ index
Who
Makers and coding agents verifying graphics and animation.
What
virtualmatter screenshot renders with the server's headless client. It skips the game's custom light pass, streams rigs at very low LOD, and poses characters at whatever rate the game allows there. --target refuses objects without their own voxels, such as rig roots. The browser editor link renders correctly, but it stalls whenever its tab or pane is hidden (B22), and its canvas lives in an iframe that page-level automation can't send held keys to.
When
Every visual check.
Where
The CLI/MCP screenshot path and the browser client.
Why
Inferred: screenshots come from a cheap server-side scene rather than a real client.

Impact

Animation, lighting and UI can only be judged in a visible real client, so verification takes longer and some results are marked unverified.

Mitigation

Geometry is checked with CLI screenshots. UI and lighting are checked in a visible browser client, with player actions driven through client-side Lua and RPCs. Workaround found later: virtualmatter screenshot --client <id> captures through a real connected client (for example the native client opened by the CLI) with the lighting pass on, which sidesteps the headless client running with SSAO and the custom light pass off.

Suggested fix

Allow screenshots from a chosen connected client (--client <id>), keep the custom shader passes, and accept --target on hierarchy roots.

B27

Time.time restarts on every Lua reload

HighWhy: measured↑ index
Who
Any script that stores a timestamp somewhere that outlives the Lua state: Config, serialized fields or a ledger.
What
After hours of uptime, Time.time read 92 s, while AE:GetDebugTime() read 59 661 s. The Terrain Keeper stored its last-save time in Config. After a hot reload that time lay in the future ("last full save −64 s ago"), which would have blocked the next save for as long as the world had been running.
When
On every hot reload, Lua reset or restart.
Where
The simulation clock restarts with the Lua state.
Why
The clock is tied to the Lua state, not to the process. That's reasonable, but it isn't documented, and the SDK guide recommends Time.time for timing.

Impact

Cooldowns, respawn timers and regrowth timers that persist across reloads silently stretch or never fire.

Mitigation shipped

The Terrain Keeper first switched to AE:GetDebugTime(), believing it to be wall-clock time; it turned out to be process uptime (B41), and a persistent game clock replaced it. The other game timers were audited: they live only in memory, or persist as "seconds left" rather than absolute times.

Suggested fix

Document the reset, and offer a monotonic per-process clock (for example Time.uptime) for anything that persists.

B28

Clients keep showing terrain the server has removed

CriticalWhy: measured↑ index
Who
Anyone connected while a large terrain edit happens, whether it is far from their camera or right in front of it.
What
A two-pass rebuild of the map's 160 m boundary (about 5 500 static ops) first left, then cleared, debris over the edge band. Afterwards the screenshot client still showed rows of rocks, pillars and brick hanging in the sky over the south and south-west edges. On the server, a 2 m grid of downward rays found no voxels above cliff height there (only the Spire tower), and no objects. The client was drawing terrain that no longer exists.

Update: it also happens close up. After about 600 static edits in the hub (rebuilding the Emberheart and paving the plaza), the screenshot client drew a large blocky "room" filling the view at roughly (0, 0–6, 5–12), inside Cinderhold. On the server, a horizontal ray from (0, 4, 14) toward −Z hit nothing, and downward rays at z = 5–13 all found the plaza floor at y = 0.34.
When
After static removes 70–100 m from a client's camera (the edge rebuild), and after many edits 2–10 m from it (the hub rework).
Where
Client static voxel streaming. The same mechanism as B3, where a client kept collision the server no longer had.
Why
Inferred: the client keeps a cached chunk after the server has changed it, until something forces a reload. Distance from the camera is not the trigger, since phantom chunks also appeared 2–10 m away. A burst of many edits to the same chunks is the common factor in both cases, which points at dropped or out-of-order static deltas, as in B3.

Impact

Players see walls and debris that aren't there: "odd walls that pop up". After any live terrain rework, the world looks different to each client depending on when they connected. In the hub, a phantom block can fill the whole view.

Mitigation

Large reworks run only when nobody is online (the World Dresser and Deep Arena upgrades ran on an empty server). The Terrain Keeper saves afterwards, so fresh connections stream the current terrain. Server-side ray audits are used as ground truth instead of screenshots. The same rule now covers hub and plaza dressing, not only map-scale reworks.

Suggested fix

Version static chunks and have clients re-request any chunk whose version changed, even outside their LOD focus. Expose a working "resync region" call.

B29

Every voxel object deep-copies its asset on the main thread

HighWhy: measured↑ index
Who
Every player whenever a character, prop or piece of debris is spawned.
What
Spawning one 5-part enemy rig froze the server for about 320 ms. Profiling showed vd.asset = template.asset at 33–83 ms per part, and CreateObject + AddComponent at about 11 ms. Scene:CloneObject was no cheaper (56–96 ms per part). Assigning the same asset a second time cost 75 ms again.
When
On every object that gets voxel content from an asset or a clone.
Where
VoxelData asset binding and object cloning.
Why
Inferred: the voxel data is copied per instance, with no sharing or instancing of identical assets.

Impact

Spawning enemies mid-fight stutters everyone. Combined with B17 (per-object cost) and B18 (clients can't instance assets), animated multi-part characters are expensive at every stage.

Mitigation shipped

A spawn pool. Six enemy rigs are pre-built while inactive, one part every 0.35 s when calm and every 1.5 s during fights. A spawn activates a pooled rig. Measured spawn cost: 4–20 ms, down from about 320 ms. Bulwarks now come from their own pre-built pool, and the Matron's 10 parts are staged before her fight (her spawn measured 10 ms), so neither binds assets mid-combat. Dead Hollows are recycled into the pool, and cosmetic voxel detail is baked into variant assets rather than stamped at spawn (see the stress test below).

Suggested fix

Share voxel data between instances of the same asset (copy-on-write), and load or bind assets asynchronously off the main thread.

Stress test: worst case

A siege wave, an Ash Hound pack, a Lantern Thief, the Matron awake and a Colossus fight at once, with 5 fake players and nobody online. Teardown afterwards was verified clean. In the baseline, the ramp-up stalled whenever the pool ran dry and a rig had to be built synchronously (about 320 ms each).

PhaseServer fpsp95p99Worst frameNotes
Baseline, ramp-up30.4–508 ms893 msSynchronous rig builds when the pool ran dry
Baseline, steady (26 Hollows + 2 bosses)50–5126–27 ms–84–94 msScript cost: Hollow 3.1 ms (39 instances), Body 1.1 ms
Round 11 fixes, steady56.321.6 ms23.8 ms29 msNo cold builds mid-fight, throttled pool rebuild, jittered AI think times, lighter static jobs
Round 11 fixes, ramp-up––341 ms663 msSpikes remained
Round 12, ramp-up45.4–65.5 ms480 msDead Hollows recycled into the pool, crust pre-worn at pool time, pool rebuild throttled rather than stopped
Round 12, steady siege (22 Hollows)53.925 ms–76 msThe siege no longer starves at 16 Hollows
Round 13, ramp-up53.3–60.9 ms73 msCrust baked into assets: spawns do no voxel ops. The only frames over 100 ms are the bosses' own staged builds at spawn, plus a 25 ms ledger encode
Round 13, steady (22 Hollows + Colossus + Matron + siege)53.4–30.5 ms51 msBaseline steady worst was 84–94 ms; the pool trims back to 14 afterwards
Round 14, ramp-up (no attribution wrappers)53.7–38.6 ms73 ms
Round 14, steady (23 Hollows + bosses + siege)54.322.5 ms30.7 ms123 msScript cost per frame: Hollow 3.3 ms (22 instances), Body 1.2 ms, Game 1.0 ms. The next round targets the last ~3 ms
Round 16 + inactive components, steady (22 Hollows + bosses + siege)54.724.6 ms30.3 ms32 msNo frames over 33 ms. Hollow, Body and Beacon server components set inactive and driven by one manager; game script time 8.66 → 4.15 ms per frame
After rounds 17–18, before the fix (22–23 Hollows + Matron + Colossus + siege)53.8–54.4–32–34 ms64–102 msScript time only 2.5 ms; the spikes were the pool building spare rigs mid-fight once the group bonus used every rig
After the pool fix (no new rigs while 18+ Hollows are out)56.0–56.123.7 ms29 ms36–37 msNo frames over 50 ms; peak load settles at 19 Hollows instead of 22

Spike attribution (resolved). Wrapping 1,436 server functions during the ramp showed that every 300–716 ms frame was a spawn running the Hollow crust builder: 7–23 Vox:Add rubble plates on the torso part, which has scale 0.19. Lua took only 12–25 ms of that; the rest was the engine committing voxels. At scale 0.19, a 0.3 m plate is about 140 times as many voxels as the same plate at scale 1 (see the scaled-part op costs under Measured limits). The fix was to bake the crust into three torso variant assets with VoxelData:SaveAs, so spawns do no voxel ops at all, and to have hits paint chips instead of carving them. Pool part binds also showed up (36–72 ms for Hollows and 40–80 ms for Bulwarks), and are now paced. One 469 ms engine-side frame with no voxel ops in round 12 is still unexplained.

Forging (acceptable now): forging armour onto a player rig first hitched at 43.5 fps, with 36 frames over 50 ms and a worst frame of 83 ms, for about 6 s. Every voxel op on a part at about 0.19 scale costs at least 6.6 ms of engine commit, typically 7–30 ms. Pre-baked tiers removed the voxel ops but brought the problems in B42. Armour is now stamped live onto small, empty shell objects at one op per frame plus two idle frames, with no binds and no SaveAs. A full tier-3 set on a dummy takes about 35–40 s at 47 fps, with a worst frame of 48–52 ms.

B30

The SDK character controller jumps 2.5 m and has no slope limit

MediumWhy: measured↑ index
Who
Makers who use the example character controller as shipped, and their level designers. Ashlight's players use it unmodified.
What
Examples/Character Controller.lua defaults to jumpHeight = 2.5 (the apex, in metres) and maxStepHeight = 0.5, and has no slope limit: steepness is handled only by the ground probe and step-up. A wall has to stand more than about 2.6 m above the highest foothold in front of it to stop a player, not 2.5 m.
When
Whenever a level relies on a wall, ledge or steep slope to keep players out.
Where
The example character controller's defaults (jumpHeight, maxStepHeight, ground probe).
Why
The defaults are tuned for a platformer-style jump, roughly twice a typical human jump, and nothing in the controller rejects steep ground.

Impact

Boundaries and "impassable" ruin walls that look tall enough can be jumped. Small interior spaces can't use ledges to gate progress at all: no ledge inside a 3 m crypt stops a 2.5 m jump.

Mitigation

Game-side wall heights. The map edge keeps at least 2.9 m above the highest foothold (a 0.4 m margin over the apex). The Undercroft crypt uses a one-way door instead of a ledge. Lowering charCtrl.jumpHeight to about 1.2 m was considered.

Suggested fix

Ship a human-scale default jump (about 1–1.2 m), add a configurable maximum walkable slope, and document the jump apex next to level-design guidance in the skills.

B31

Scaled rig parts: forced rays miss them and their units don't match the world

MediumWhy: inferred↑ index
Who
Anyone adding voxel detail to a segmented character at runtime (armour, damage, decals), and anyone who tests that code offline first.
What
Armour stamped live onto a Warden rig came out as an oversized, boxy shell of plates that hid the head, although it passed every offline mock. On the live rig, every part (head, torso, shin) has scale 0.1926 and the same position as the root; each part's shape is offset inside its own voxel space. A ray with Force(shinPart) hit nothing at a point where the same ray without Force hit the shin at 1.29 m.
When
When code measures or edits a part using local offsets that assume scale 1, or relies on a Force-filtered ray to find a part's surface.
Where
The SDK's flat-rig convention (parts parented to the root, geometry offset in voxel space) combined with scaled VoxelData; Cast:Ray(...):Force(part).
Why
Inferred: world, voxel and local units differ per part once VoxelData is scaled, and the force filter does not reliably resolve hits against some scaled parts. Nothing reports the mismatch, so the maths silently produces the wrong size.

Impact

Runtime character customisation is error-prone, and offline tests give false confidence unless they model scaled flat parts. The failure is visual and only shows on a live client.

Mitigation

The armour visuals were reverted within minutes, before any player saw them. They were later rebuilt from measurements taken on the live parts, and now stamp slowly onto small shells (B42); offline mocks have to model scaled flat parts.

Suggested fix

Make Force(obj) rays honour the object's scale, and expose helpers that convert between world, local and voxel space per object (for example obj:WorldToVoxel(p)). Document the flat-rig convention, including where a part's geometry sits, in the segmented-character skill.

B32

Every Lua reload destroys runtime-built objects

MediumWhy: measured↑ index
Who
Any game that pre-builds pools or staged assets at runtime, and every player online when a script is deployed.
What
After a hot reload (a file save or Server:LuaReset), the six pooled Hollow rigs were already gone before the game script's Start() ran. They were inactive, parked at y = −60, with save = false. A debug-logged scan of Scene:GetAllObjects() in Start() found none of them. The pool then rebuilt from scratch: one rig ready at t = 4 s, all six at t = 16 s.
When
On every Lua reload.
Where
The reload path: objects with save = false are removed before any script's Start() runs.
Why
Reload appears to reset the scene to its saved state, so only save = true objects survive. Combined with B29 (each rebuilt rig part costs a 35–95 ms asset bind), a reload re-pays the full cost of every pool.

Impact

Every deploy while players are online costs roughly 10–30 s of stutter. QA measured 53.8 fps with a worst frame of 109 ms after one reload. Together with B8, a live edit costs both the work in flight and everything pre-built.

Mitigation

None found in Lua short of persisting pools with save = true, which bloats the saved scene. Adopting the surviving pool after a reload was tried and reverted, since nothing survives to adopt. Deploys are batched and made when nobody is online where possible.

Suggested fix

Offer a runtime-only flag that keeps an object across Lua reloads without saving it to the scene, or let scripts hand objects to their next instance through an OnBeforeReload hook (see B8).

B33

No per-object time scale, and no gradient or vignette in the UI

LowWhy: measured↑ index
Who
Makers building melee combat feel and screen-space damage feedback.
What
Melee hit-stop needs the striker's and the victim's animation to pause for about 60–120 ms while the world keeps running. The engine offers only the global Time.timeScale, which slows every object, the server simulation and every client. Screen-edge damage feedback hits a similar gap: MUI panels are flat colours, with no gradient or vignette primitive.
When
Whenever a game wants local slow-motion or soft screen-edge effects.
Where
Time.timeScale; MUI panel styling.
Why
Time scaling exists only globally, and the UI has no per-panel gradient fill.

Impact

Common combat-feel techniques need workarounds in game code, which cost client time and are easy to get wrong.

Mitigation shipped

The client wraps each character rig's update and feeds it 5% of the frame time during a hit-stop; the rig still follows its root, and its springs hold. The directional damage vignette is built from four edges of two overlapping translucent bands (a stepped gradient), updated only when the quantised alpha changes. Both are deployed and verified server-side.

Suggested fix

Add a per-object (or per-hierarchy) time scale that scripts and animation read, and a gradient fill for MUI panels.

B34

New scripts can't be attached for about a minute after they sync

LowWhy: inferred↑ index
Who
Makers and agents who add a new script file and attach it straight away.
What
AddScript("Ashlight/Combat FX.lua"), run right after the file synced, failed with "Script file does not exist". The same call worked about a minute later, once the server had written the file's .zma metadata (seen as Combat FX.lua.zma appearing in the mirror).
When
For roughly a minute after a new .lua file first syncs.
Where
Asset registration of new script files on the server; Object:AddScript.
Why
Inferred: a script only becomes an attachable asset once its metadata has been written, and that happens on a delay after the file itself arrives.

Impact

Deploy scripts that create and attach a new module in one step fail on the first try, and the error suggests the file is missing rather than not yet registered.

Mitigation

Wait for the .zma to appear in the mirror, or retry AddScript, before attaching a new script.

Suggested fix

Register a script as soon as its file lands, or have AddScript wait for a pending registration. At minimum, say "not yet registered" instead of "does not exist".

B35

A coding harness can't run automated playtests

MediumWhy: measured↑ index
Who
Agents and makers who build through the CLI and want to test gameplay without a human at the keyboard.
What
Two problems. First, over about 10 minutes, roughly 1 in 3 CLI run-lua and errors calls returned "fetch failed" with no HTTP status, then worked on retry. Second, a native client launched with virtualmatter open joins in edit mode: its player character is spawned but not simulated, so neither a client-side charCtrl:setPosition nor server-side writes move it.

Update (QA): after a server restart, about 1 in 6 read-only run-lua calls (0.1–1 s of work each) returned only "fetch failed": no HTTP status and no engine error, unlike the 504 timeout. An identical retry 15–20 s later succeeded every time. There is no way to tell whether the failed call's code ran.
When
The fetch failures were intermittent, during normal building. The edit-mode client happens on every CLI launch.
Where
The CLI's connection to the session; the native client's launch mode.
Why
The CLI-launched client opens the editor view with a fly camera rather than play mode. The cause of the fetch failures isn't visible from outside the platform.

Impact

Boss fights and traversal (for example, falling into the Undercroft vault) can't be playtested automatically from a coding harness, so they need a human playtester. Flaky calls also make long verification scripts fail partway through.

Mitigation

A retry wrapper with backoff for fetch failures. The CLI's sign-in can also expire mid-session ("Not signed in"), after which a run-lua stops to ask for a device login. For Lua that changes the world, read back its effect before retrying, the same rule as for a 504. Boss logic is verified in offline harnesses plus server-side state probes, with the final feel check left to a human.

Suggested fix

Add an open --play mode (or a headless play client) whose character can be driven from run-lua, and have the CLI retry transient network failures itself and report them with a status.

B36

Left-handed parallelepipeds silently build nothing

HighWhy: measured↑ index
Who
Anyone building oriented boxes with Shape.Parallelepiped(center, a, b, c): cliff facets, banners, lids, armour plates.
What
When the three axes are left-handed (dot(a, cross(b, c)) < 0), the Vox op runs, returns no error, and adds, removes or paints no voxels. A parallelepiped is symmetric, so the same box with c negated (right-handed) builds normally. The World Dresser built every north and east edge facet with left-handed axis triples, (1,0,0)/(0,1,0)/(0,0,−1) and (0,0,1)/(0,1,0)/(1,0,0). All 327 of them are missing live: the north and east cliffs, the north and east runs of the NW and SE corners, and 4 plaza banners. Every right-handed facet on the south and west edges stands, and the rubble placed on top of the missing facets floats at y = 8–9. Two sarcophagus lids in the Undercroft and six arch and coping pieces on the Refuge islands are missing for the same reason.

Update: the damage was wider. Every Hearth edge piece (wall, doorway wall, window wall and door) was a left-handed parallelepiped, so player homes have never had walls or doors: they were roofs on foundations. Also missing: 18 of the 38 Spire-lair pieces, the Colossus's left-arm plates, the Matron's robe train, the Abbot's scythe blade, and every crust cavity and crack line in the Deep Arena, so its crust cells never really opened. The SDK's own Script Load Helper/Explosion.lua builds about half of its debris shapes empty for the same reason. Armour and weapon plates were checked and are unaffected: they are built as (t, n × t, n), which is always right-handed (0 of 955 armour plates were left-handed).
When
On every op whose parallelepiped axes form a left-handed basis.
Where
Shape.Parallelepiped as used by Vox:Add, Remove and Paint.
Why
Measured: a mock that drops left-handed parallelepipeds reproduces QA's ray height maps cell for cell. The shape's inside test presumably assumes a positive determinant, so a negative one makes every point test as outside.

Impact

Whole sections of a world go missing with nothing in the error log, and anything placed on top of them floats. It surfaced as the "boulders hanging in the air" over the north and east edges. Worse, it quietly removed core mechanics: homes that were never enclosed undercut the shelter system from launch, and a boss arena whose crust could never open. Any game that uses the SDK's explosion debris inherits the bug.

Mitigation

Normalise handedness in the game's parallelepiped helper before every call: if dot(a, cross(b, c)) < 0, negate c. The north and east cliffs were rebuilt this way (1,287 ops, 0 errors, with nobody online, followed by a full terrain save). A live ray audit afterwards found the lowest crest point at 6.7 m on the north edge (was 0.5 m) and 5.7 m on the east (was 0.0 m), 7.1 m and 6.9 m in front of the Spire, and no floating cells on either side. The four missing plaza banners were laid again. Every builder now goes through the guarded helper, and fixes were verified live by probe: home edge pieces 0 of 3 → 3 of 3, with a boot-time rebuild of any missing piece; Spire-lair buttress tops 0.7 → 5.8 m, keystone placed; Deep Arena rebuilt, with a 1.9–2.4 m cavity under every sampled crust cell (was none).

Suggested fix

Normalise the basis inside Shape.Parallelepiped, or at least raise an error for a left-handed or degenerate basis.

B37

Voxel ops never report an empty result

MediumWhy: measured↑ index
Who
Anyone building worlds procedurally through the Vox API.
What
Neither Run() nor OnFinished reports how many voxels an op changed. A degenerate shape (a left-handed parallelepiped as in B36, a zero-size box, or an op wholly outside its VoxelData) fails silently, and the op queue still reports it as done.
When
On any op that ends up changing nothing.
Where
The Vox op pipeline and its completion callback.
Why
The completion path carries no result count or warning.

Impact

Silent no-ops are found only by auditing the world afterwards, often hours later and by a different agent. B36 went unnoticed through several builds for this reason.

Mitigation

Procedural builds are verified with read-only ray audits (height maps and upward "underside" rays) rather than by trusting the queue's "done". Because ops are asynchronous, a readback in the same run-lua call still sees the old voxels: a repair script once reported a crate's old height although the remove had worked. Verification now runs as a separate call a second later, or through :OnFinished.

Suggested fix

Pass the number of voxels changed to OnFinished, and log a warning when an op changes nothing.

B38

SDK editor scripts cost about 3 ms per server frame when idle

LowWhy: measured↑ index
Who
Every world, whether or not anyone is playing.
What
The script profiler on the live server, with nobody online, showed five SDK and editor scripts taking 3.0 ms per frame between them: Chat 1.25 ms, Click To Play Prompt 0.80 ms, Particle Updater 0.51 ms, Voxelizer 0.29 ms and Shape Manager 0.17 ms. All of Ashlight's game scripts together took under 0.6 ms.
When
Every server frame, including on an empty server.
Where
The platform's built-in Lua scripts that ship with every world.
Why
These scripts update every frame whether or not there is anything for them to do.

Impact

At idle, platform scripts cost at least five times as much as the game's own. With the thin headroom in B17 (about 4 ms spare per frame at 60 fps), that is a large share of the budget spent before any gameplay runs.

Mitigation

None from game code: the scripts belong to the platform.

Suggested fix

Make these scripts event-driven or sleep them when idle (no chat messages, no pending prompts, no particles, no voxelize or shape jobs), and let a world disable the ones it doesn't use.

B39

Server memory climbs on an idle, empty server

LowWhy: inferred↑ index
Who
Long-running worlds, and hosts that pack several worlds per machine.
What
QA memory samples after a restart showed the server process growing from 12.6 GB to 15.9 GB over 75 minutes with nobody online. The largest grower was the Voxel DB atlas, from 3.76 GB to 5.0 GB (about 17 MB per minute). The surface store and the Lua heap stayed flat. Before the previous restart, memory had plateaued at about 20.8 GB.

Update: the atlas then held at exactly 5,000 MB for over 25 minutes, which looks like an engine cap. Total memory reached 16.5 GB and kept growing at about 7 MB per minute after the atlas stopped; the surface store was 5,251 MB and the Lua heap 12.4 MB. Idle performance stayed healthy throughout: 57.7–58.4 fps, 11.3–11.5 ms per update, worst frame 22–27 ms.
When
Continuously after a restart, on an idle server.
Where
The engine's Voxel DB atlas.
Why
The atlas fills a cache up to a fixed 5,000 MB cap (measured). The remaining slow growth of about 7 MB per minute comes from somewhere else, and its source is unknown.

Impact

An idle world gains gigabytes of memory, so its footprint depends on how long it has been up rather than on what players are doing. If the plateau doesn't hold, a long-running world eventually runs out of memory.

Mitigation

The atlas is confirmed capped; the residual growth was still unexplained when the project paused. Nothing in game code touches either. With sessions running, memory grew faster: 13.2 GB after a restart to 16.7 GB 30 minutes later (limit 32 GB).

Suggested fix

Cap the atlas with a documented, configurable budget, and expose its size and eviction counts through Server:GetMemoryUsage().

B40

Inactive objects ignore forced raycasts

LowWhy: measured↑ index
Who
Anyone measuring or decorating pooled or template objects while they are parked out of sight.
What
Cast:Ray(...):Force(obj):Run() returns nil for an object that is not activeInHierarchy, even with the right transform and voxels. This was seen on Rig Library templates and on pooled enemy rigs.
When
Whenever the target object, or any of its parents, is inactive.
Where
Cast:Ray with Force.
Why
Inferred: raycasts only consider active objects, and Force doesn't override that. Nothing in the result says the target was skipped.

Impact

Pools (B29) keep objects inactive precisely so they cost nothing, which is exactly when a game wants to measure and dress them. Together with B31, forced rays are unreliable for any rig that isn't live and at scale 1.

Mitigation

The Hollow pool activates a parked rig (at y = −60) only while it is being dressed, and only between fights.

Suggested fix

Let a forced ray hit its target regardless of active state, or return an explicit "target inactive" result instead of nil.

B41

AE:GetDebugTime() restarts with the server process

HighWhy: measured↑ index
Who
Any game that persists timestamps across server restarts: respawn timers, cooldowns, rate limits, save schedules.
What
The server restarted unexpectedly at about 22:24. Memory was at 13.1 of 32 GB, so it wasn't out of memory, and the cause is unknown. Right afterwards, AE:GetDebugTime() read 79 s: it is process uptime, not wall-clock time. Every timer Ashlight had persisted as "wall clock" was wrong after the restart: the Abbot's 45-minute respawn, the siege's once-an-hour limit, the Omen ward and Abbot-stirs timers, and the Terrain Keeper's save gaps. Game state saved every 2 minutes was reloaded from its last disk save, so a one-shot world repair made just before (re-seating a stacked home) was reverted.
When
On every server process restart, planned or not.
Where
AE:GetDebugTime(); the platform's restart behaviour. The CLI's own agent guide lists AE:GetDebugTime() as "wall clock", next to Time.time (simulation time).
Why
The sandbox removes os.time, and no wall-clock or persistent clock replaces it. The only clocks are simulation time, which restarts on every Lua reload (B27), and process uptime, which restarts with the process.

Impact

Timers that persist across restarts silently fire early, or never, after a restart. With no visible cause and no warning before the restart, the last few minutes of game state are lost, along with any repair made in that window.

Mitigation shipped

A persistent game clock: seconds accumulated in the ledger, plus the uptime since boot, with older timestamps clamped to "now". Downtime doesn't count toward any timer. The ledger is saved to disk immediately after any one-shot repair, and a boot pass re-applies home repairs on its own. One edge case showed up after a later restart: the Terrain Keeper logged "last full save 1000008593 s ago" (an edge case in the game's clock clamp) and ran a full terrain save right after boot. That was harmless on an empty server, but a full save freezes the server for 33–60 s (B19), so the edge case matters. Restarts themselves are tracked in B44.

Suggested fix

Expose a real wall-clock time (for example Time.unix), correct the guide's "wall clock" label, and log or announce the reason for every server restart.

B42

Baked rig variants are huge, every bind stalls a frame, and a periodic freeze came with them

MediumWhy: inferred↑ index
Who
Games that bake cosmetic detail into rig assets to avoid runtime voxel cost (B29), their makers' mirrors, and every client that streams the assets.
What
VoxelData:SaveAs on a scaled rig part stores the part at its fine internal resolution, so every baked variant is a full copy. Mace tiers came to about 7 MB each, and the 111 armour and weapon variants to 329 MB of .am assets, all of which sync to the maker's mirror and have to stream to clients. Separately, the patch that switched characters over to the baked armour introduced a freeze of about 700 ms every 8.4 s on an idle server, with the heap growing about 2 MB per freeze and no game Lua running inside it. Reverting that patch fixed it at once (58.2 fps, worst frame 28 ms).

Update: the freeze came from bake code inside the character script, not from the assets themselves. With that code removed, an A/B test showed no freezes in about 7,000 frames both with and without the baked templates loaded. Binding a baked part still costs 100–500 ms each (forging from full baked part copies: 5 frames over 50 ms, worst 502 ms).

Update (armour shells): to shrink the binds, only the armour voxels were baked onto small, separate "shell" objects at the part's scale, about 20–35% of a part. Binding them still blocked the server frame for 40–360 ms, almost regardless of size:
Bound objectVoxelsSynchronous bind (Lua time)
Renown shell15498 ms
Greaves shells329 / 1,17540 / 82 ms
Helm shells995 / 3,53565 / 91 ms
Mail shells2,468 / 6,924 / 7,827158 / 277 / 358 ms
Weapon shells89–96k1.2–1.4 s
Plain parts, for comparisonhead / torso / mace114 / 265 / 414 ms
Lesson
A bind has a large fixed cost (about 40–100 ms) and lands in a single frame, so "bake and bind" can't make runtime swaps smooth: pre-built objects have to be activated, not bound. The character code for the shells also brought the periodic ~700 ms idle freeze back, so it was reverted too. Thin, spread-out assets cost far more than their voxel count suggests: the 89k-voxel mace shell took 1.2–1.4 s to bind, against 414 ms for the solid 112k-voxel mace.
When
When baking variants of scaled parts; the freeze appeared once the baked variants were wired into the character script.
Where
VoxelData:SaveAs on scaled VoxelData; asset handling for large numbers of voxel assets.
Why
The size follows from the scaled part's internal resolution (see B29 and B31). The bind cost is engine-side and synchronous. The freeze is not proven, but it correlates with heavy SaveAs use: both sessions that ran 75–111 bakes then showed a ~700 ms engine-side frame every ~8 s at idle (heap +2 MB each time), a Lua reload cleared it, and sessions that only bound assets never showed it.

Impact

Baking trades a voxel-commit stall for disk, sync and streaming size, and here also for a periodic server-wide freeze. Neither runtime stamping (B29) nor baking was a clean way to give characters per-tier detail; the eventual answer was slow live stamping onto small shells.

Mitigation

Armour no longer binds or saves assets at all: it is stamped live onto small shells at one op per frame (47 fps, worst frame about 50 ms, over about 35 s per full set), and an idle server showed no freezes over 100 ms in 6,957 frames afterwards. The 111 baked variants (329 MB) and the 75 shell templates (60 MB) were deleted. Hollow crusts keep their three baked torso variants, which are bound once at pool time, never mid-fight.

Suggested fix

Let SaveAs resample to a target resolution, or support instancing and sharing of voxel data between variants. Keep any periodic asset scan incremental, and expose its cost in the engine timers.

B43

The server renders a headless client every frame

MediumWhy: measured↑ index
Who
Every world, at all times.
What
Engine timers on the live server with nobody online: Update Frame (server) 9.8 ms, Render Frame 6.2 ms, Update Frame (FirstScene) 1.7 ms, and Scene/Objects 5.7 ms for 266 active objects (about 21 µs each). FirstScene is the built-in headless client scene that the screenshot tool uses. So about 8 ms of every server frame goes to rendering and updating a client nobody sees, and game update plus that render comes to about 17 ms: roughly 58 fps at idle.
When
Every server frame, whether or not anyone is connected or taking screenshots.
Where
The server process's built-in client (the FirstScene scene and its render frame).
Why
The headless client used for screenshots and agent tooling runs and renders continuously instead of on demand.

Impact

A server can't reach 60 fps even with no players, and the ~8 ms comes straight out of the thin headroom in B17. Together with B38 (idle SDK scripts), much of the frame budget is spent before any gameplay runs.

Mitigation

None from game code. The only lever is fewer active objects, which the game pursues through pooling and inactive components (see What held up). A census found only 101 active objects left, so there is little more to cut.

Suggested fix

Render the headless client only while a screenshot or agent capture is pending, or throttle it to a low rate, and show its cost separately in the engine timers.

B44

The server restarts without warning or a reason

HighWhy: unknown↑ index
Who
Everyone connected, and any game state not yet written to disk.
What
The live server restarted three times in one night with no warning and no reason given: at about 20:13, at about 22:24, and at about 01:10. Memory stood at 13.1 GB and 14 GB of 32 GB at the last two, so none of them looks like running out of memory. The 01:10 restart came during the first live forge measurement; repeating the same forge five minutes later ran to completion without one, so the trigger is unknown.

Update: a fourth restart came at about 01:35 (uptime reset to 46 s), while a background job was stamping small voxel ops onto pooled Hollow rig parts (scale about 0.19) at one op every three frames. Just before it, that "idle" dressing measured 25.1 fps, an average update of 38.9 ms, 32 frames over 50 ms and a worst frame of 596 ms; an offline mock had predicted about 8 ms per op frame. Three of the four restarts happened during live voxel stamping on scaled rig parts or shells: at about 22:24 (the first armour stamping), 01:10 (a forge) and 01:35 (Hollow dressing).

Update: two more restarts came the next morning, with no stamping running: at 07:22, while a playtester in a browser walked from the tutorial island to the hub (gameplay had been very choppy first, and no Lua errors preceded it), and at 07:58 with a test client connected. At that point three orphaned sync processes were running at once and pulling the 370 MB terrain in parallel on every terrain save (B48). That is a likely contributor, but it is unproven.

Update (#7): the play server restarted while a playtester fought the Gate-Warden ("Disconnected for unknown reason"). Sync was off and no deploys were running, so orphaned syncs can't be the only cause; the server came back on the right terrain. The leading theory is runtime voxel ops on scaled rig parts with players connected. Every crusted hit had been painting a ~0.19-scale torso part (about 140 times the voxels of the same shape at scale 1), with no cap across players or Hollows. And the Gate-Warden's knight rebuild, which runs 68 stamping jobs after every Lua reload, stamped regardless of nearby players once the server had been up for 10 minutes.
When
Three of seven during sustained voxel stamping on scaled rig parts, and one more during a boss fight where scaled-part ops were running; two the next morning while orphaned syncs were downloading the terrain (B48); one (about 20:13) under ordinary load.
Where
The server process or its host.
Why
Unknown. Nothing visible from inside the world, or through the CLI, records a cause. The link to stamping on scaled parts is a correlation, not proof: a repeat of the 01:10 forge did not crash.

Impact

Players are dropped, process-uptime clocks restart (B41), and everything since the last disk save is lost: the 22:24 restart reverted a world repair made minutes earlier. A game can't plan for restarts it can't see coming or explain.

Mitigation

The game assumes a restart can happen at any moment: a persistent game clock, an immediate ledger save after one-shot repairs, and boot passes that repair homes and replay the terrain journal. Every remaining runtime op on a rig now goes through one global budget (at most 4 ops/s in total, one per 0.6 s per Hollow, and none on a live rig with a player within 80 m), crust chips cost no voxel ops at all, and the Warden's rebuild never stamps with anyone within 80 m of its gate. In the siege smoke test, rig ops fell from about 34 in 115 s (peak 7/s) to none. Continuous background stamping on scaled rig parts is shelved: the Hollow dressing was reverted (idle back to 59.7 fps, worst frame 17 ms). Player forging stays, because it is short, player-triggered and measured at a worst frame of about 50 ms. After the 01:10 restart the game clock (8,748 s against 204 s of uptime), the home re-seat, the dressed world, the Undercroft, lore and Omens all survived.

Suggested fix

Record the reason for every restart (crash, host maintenance, watchdog, deploy) where makers can read it, warn connected players before planned restarts, and give scripts a shutdown hook to flush state.

Status at the pause

Open. Seven restarts between about 20:13 on 6 Oct and 10:35 on 7 Oct, with no cause reported by the platform. Mitigations reduced the leading suspects (stamping on scaled rig parts, orphaned syncs), but the server can still restart at any moment, and the game is built to survive it.

B45

After a server restart, sync silently fails every push

HighWhy: inferred↑ index
Who
Makers and agents who keep virtualmatter sync running while they edit, which is the documented workflow.
What
After the 01:35 restart (B44), the long-running sync process started failing every push with HTTP 403 and kept running as if nothing had happened. Three deploys looked done locally (the files compared identical to the staged copies) but never reached the server. Restarting sync fixed it.
When
After a server restart, until sync is restarted.
Where
The CLI's sync command and its session with the server.
Why
Inferred: sync keeps a session that the restart invalidates, and it neither renews the session nor stops on the 403. Some of these failures may have come from an older, orphaned sync process that was still running (B48).

Impact

Deploys silently don't happen. A local check that the file was written (the deploy step's cmp) passes, so the maker believes the world is running code it isn't. Combined with B44's unannounced restarts, any long session can lose deploys without noticing.

Mitigation

Restart sync after any server restart, and confirm "pushed" lines in its log for each deploy rather than relying on a local file comparison.

Suggested fix

Have sync re-authenticate on a 403, and exit or print a loud error if it can't, instead of continuing silently.

B46

Server Lua has no stable per-user id

HighWhy: measured↑ index
Who
Any game that keeps per-player progress: characters, inventories, homes, unlocks.
What
Server Lua gets no stable user id. The only identity is the client-supplied chat name, and it arrives by RPC after clientConnected. Ashlight keyed characters on the name it had at join time, which at that moment was a placeholder ("Wanderer <connection id>"). All 7 live character records turned out to be Wanderer-N, so no player's progress had ever survived a reconnect. In a browser, a signed-in maker's chat name is their platform handle; anonymous visitors get a random "Adjective Animal" name on each visit.
When
On every join; the chat name took 11 s to arrive for a native client.
Where
The join path (clientConnected) and the chat-name RPC.
Why
Identity is a display name the client sends after connecting, not an account id the server knows at connect time.

Impact

Persistent progress silently breaks unless a game knows to wait for the name, and even then it rests on a name the client controls. Anonymous visitors can't keep progress at all, and two players with the same handle would share a record.

Mitigation shipped

The game holds each join until the chat name arrives (up to 20 s) and refuses to save records under placeholder or guest names. Verified with a native client: it waited 11 s, then joined under its chat name. Playtesters are asked to sign in so their character persists.

Suggested fix

Pass a stable, opaque per-account id (and the display name) to server Lua at clientConnected, with a flag for anonymous sessions.

B47

type() of a Vec3 returns "Vec3", not "userdata"

MediumWhy: measured↑ index
Who
Anyone who validates arguments with standard Lua type checks, and anyone who tests against mocks that use plain tables for vectors.
What
In this engine, Lua's type(v) on a Vec3 returns "Vec3". Standard Lua would report a host object as "userdata", and this isn't documented. Two guards written as type(pos) ~= "userdata" and type(pos) ~= "table" and type(v) == "userdata" therefore rejected every real vector, silently.
When
2026-10-07, found by a live server-side smoke test that dumped the guards (type=Vec3); the bug had been live since the pings shipped.
Where
Ashlight's pings and client combat effects; the engine's type() for Vec3 (and possibly other host classes).
Why
The engine overrides type() to return the host class name, and neither apiEmmyAtomontage.lua nor the skills say so. Offline mocks used table vectors, so their tests passed.

Impact

Pings (3) never broadcast to anyone, and hit, parry and death effects fell back to default positions, with no error raised anywhere.

Mitigation shipped

Vectors are duck-typed instead: a value counts as a vector if .x, .y and .z are numbers. The re-run smoke test passed 16 of 16, including the ping broadcast.

Suggested fix

Document the behaviour in apiEmmyAtomontage.lua and the scripting skill, or make type() return "userdata" as standard Lua does and expose the class through getmetatable or a typeof helper.

B48

virtualmatter sync leaves orphans and pushes things it shouldn't

HighWhy: measured↑ index
Who
Makers and agents running sync for a long session, which is the documented workflow, and everyone in the world they are editing.
What
Stopping a backgrounded npx virtualmatter sync killed only the npx wrapper; the node child kept running. Three syncs were live at once (started at 01:51, 07:26 and 07:44). Between them they:
  • pulled the 370 MB terrain .am, including the engine's half-written .am~ save temp, in parallel on every server terrain save;
  • reported a false "changed both here and in the world" conflict on every push (the saved conflict copy was byte-identical to the pushed file), sometimes with no "pushed" line;
  • pushed partial .vmdownload-* fragments into the world (three were found and deleted) and ping-ponged scene files;
  • re-pushed unchanged scripts, which caused two full Lua resets mid-session.
When
2026-10-07, over a morning session, after earlier syncs had been "stopped".
Where
The CLI's sync command: its process handling, its file filters and its conflict check.
Why
The child isn't tied to the wrapper's lifetime, and sync handles voxel and temporary files it can never push (they exceed the 8 MB push limit) as if they were ordinary files. The concurrent multi-hundred-MB downloads during terrain saves are a likely, unproven, contributor to the restarts in B44.

Impact

Hidden sync processes compete with each other and with the server, corrupt the world with partial files, reset every script mid-session, and make real conflicts impossible to spot. Some of the B45 failures may also have come from an orphan.

Mitigation

Long-running sync was stopped. Deploys now go through a push-only script built on the CLI's own files client, which pushes only the files named and verifies each one.

Suggested fix

Kill the child process when the wrapper exits or the console closes. Skip voxel assets and temporary files (*.am, ~, .vmdownload-*) that can never be pushed under the 8 MB cap. Don't re-push unchanged files. Add a one-shot push command.

B49

A force-killed client stays connected for 1–2 minutes

MediumWhy: measured↑ index
Who
Games whose world logic depends on who is online, and testers who close clients abruptly.
What
A native client that is force-killed stays in Server:GetClients() for about 1–2 minutes. During that time the world counts as occupied: fuel burns and Dimmings run, and the Terrain Keeper's empty-server save is blocked. Test sessions this way drained the Emberheart to 197 fuel, and it had to be refuelled to 800.
When
After a client is killed rather than closed.
Where
The server's client list and its disconnect timeout.
Why
Inferred: the server only drops a client once a connection timeout expires.

Impact

Anything keyed on "is anyone here" is wrong for a minute or two after a crash or a killed client, including the only safe moment to save the terrain (B19).

Mitigation

Close clients gracefully where possible.

Suggested fix

A shorter disconnect timeout, or a heartbeat-based "last seen" that scripts can read.

B50

Client transform race on NPCs

LowWhy: measured↑ index
Who
Scripts that take over an object's transform on the client by turning off receiveTransform.
What
Warden Aldric was drawn about five times too big at the hub on a slow client. The NPC's client animation captured the object's position and scale and set receiveTransform = false on its first frame. On a slow client that ran before the first streamed transform arrived, so the default scale of 1 was frozen in.
When
On clients where the script's first update runs before the first transform update.
Where
Client-side receiveTransform and the order of transform streaming.
Why
Nothing tells a client script whether it has received the object's real transform yet.

Impact

Visual only, but it varies by client speed, so it is easy to miss in testing on a fast machine.

Mitigation

The server publishes the rest transform (position, scale, yaw) in a networked table, and the client waits for it before taking over.

Suggested fix

Expose a "transform received" flag or event on the client, or apply the first streamed transform before handing control to scripts.

B51

Publishing to a play server can boot the wrong scene, then save over the published one

HighWhy: measured↑ index
Who
Makers who publish a world with large voxel files to a play server, and the players who open the link.
What
Each "Update play servers" restarted the play server before the 390 MB terrain upload had finished, so it booted the old starter scene ("Plane 52m Dark Tile4m"). With "Persisting scene changes" on, the running game's regular scene save then wrote that plane-linked scene back over the published scene on the play server, so every later update booted the broken scene again. The project page's per-server status also stayed "queued" for 15+ minutes after the sync had finished, including after a Restart.

Update: on a later publish, the first "Update play servers" restarted the server and reported finished while delivering none of the changes; only then did the page say "Some of your editor's content never reached every play server". A second update delivered the scripts and music, restarted again, and the repainted 390 MB terrain landed two minutes after the boot, so it only takes effect on the next cold start (play servers sleep 15 minutes after the last player leaves).
When
2026-10-07, on the first publishes to a Europe play server.
Where
The play-server update path (restart order versus file upload), scene persistence, and the project page's server status.
Why
The update doesn't wait for large files to land before restarting, and nothing stops a running server from persisting a scene that booted on the wrong assets.

Impact

The published link showed an empty plane instead of the game, and the fault fed itself on every update. The stale "queued" status hid whether an update had landed.

Mitigation shipped

A boot-time repair (Ashlight Game:repairTerrainLink()): if the static terrain is the starter plane and an Ashlight terrain asset exists, it relinks the real terrain and saves the hierarchy. It does nothing in the editor. After a scripts-only update, the play server booted on the right terrain, with the pool, tutorial islands, Emberheart and NPCs in place.

Status at the pause

Resolved for Ashlight: with the boot-time relink in place, every later publish booted on the right terrain, and the play server was checked by direct probes instead of trusting the "queued" status. The platform-side race remains.

Suggested fix

Restart a play server only after every file of the update has landed (or boot from a consistent snapshot), don't let a running server persist a scene over the published one, and keep the per-server status accurate.

B52

Play servers boot a different scene from the one the editor saves

MediumWhy: measured↑ index
Who
Makers publishing for the first time.
What
A fresh play server boots Scenes/InitScene.aeScene, which was still the day-1 starter world, while every save the game made in the editor went to the editor's session file, PlayScene.aeScene. The editor's "current scene" reported InitScene with no unsaved changes even though InitScene on disk was the old starter. Nothing warns about the mismatch.
When
On the first publish.
Where
Scene selection for play servers versus the editor session; the editor's scene status.
Why
The play server and the editor use different scene files by default, and the editor's status reports the wrong one.

Impact

The first published link showed an empty starter world. Fixing it meant backing up the scenes and saving the live world over InitScene by hand. Separately, the editor's Push button is not a publish: it pushes to the platform's AtomontageInc GitHub repository, which is easy to click by mistake when looking for publish controls.

Mitigation

With the maker's approval, the scenes were backed up and the live world saved as InitScene, which now links its own copy of the terrain.

Suggested fix

Warn at publish time when the scene a play server will boot differs from the editor's working scene, report the editor's scene status correctly, and label the Push button clearly.

B53

Generated asset names are normalised

LowWhy: measured↑ index
Who
Code that generates assets (audio, images, models) and then looks them up by the name it asked for.
What
The engine changes the requested name: "v2" becomes "V2", and "V2L" becomes "V2l". A lookup by the exact requested name finds nothing.
When
On every generated asset whose name has unusual capitalisation.
Where
Asset naming for generated files.
Why
Names are normalised on save, and the normalisation isn't documented or returned to the caller.

Impact

Generated music silently failed to play until the lookup was changed. It is easy to mistake for a failed generation.

Mitigation

The music code looks names up case-insensitively, with a fallback to the previous version (a MUSIC_V2 switch with v1 fallback).

Suggested fix

Keep names exactly as requested, or return the final asset name in the generation callback.

B54

Text-to-music sometimes returns a 5–15 s clip for a requested loop

LowWhy: measured↑ index
Who
Games that generate music beds.
What
Asked for longer looping beds, text-to-music returned some takes only 5–15 s long. The Boss and Dimming beds had to be regenerated (saved as "… V2l") because their first takes were that short.
When
Intermittently, on music generation.
Where
Text-to-music generation.
Why
The requested duration isn't guaranteed and nothing flags a short result.

Impact

A 5–15 s loop repeats audibly and breaks the mood the bed was meant to set.

Mitigation

Check the duration of each generated bed and regenerate short takes.

Suggested fix

Honour the requested duration, or report the actual length in the callback so callers can retry automatically.

B55

Networked tables adopt the tables they're given

LowWhy: measured↑ index
Who
Scripts that store the same Lua table in more than one networked table.
What
Assigning a table into a networked table (util:makeNetworkedTable) adopts it and gives it a metatable. Assigning that same cached table to a second networked table then errors with "table can not have metatable". It surfaced when a cached back-profile table was shared between two characters.
When
On the second assignment of the same table.
Where
Networked tables.
Why
The networked table takes ownership of nested tables instead of copying them, and this isn't documented.

Impact

An error at runtime that only appears once a second object shares the cached value.

Mitigation shipped

Each character now gets a plain copy of the cached table.

Suggested fix

Copy nested tables on assignment, or document the adoption and give a clearer error.

B56

MUI panels don't wrap by default, though the docs say they do

LowWhy: measured↑ index
Who
Anyone building screen UI with the MUI/NewUI widgets.
What
A panel's overflow defaults to "Hidden": children stay on one line and anything past the edge is clipped. The only other value is "Wrap". Six 80-unit children in a 300-unit box gave one clipped line with "Hidden" and two lines of three with "Wrap". The agent guide's cheat sheet describes MUI as a "flexbox-like row-wrap layout".
When
2026-10-07, while rebuilding Ashlight's whole interface (round 21).
Where
MUI panel layout (overflow); the SDK agent guide.
Why
The default doesn't match the documented behaviour.

Impact

Cards, chip rows and multi-line text silently clip instead of flowing, and the mismatch costs a debugging round.

Mitigation

The game's frame helper defaults to overflow = "Wrap", and other multi-line containers break lines explicitly with endLine.

Suggested fix

Make the docs say "Hidden" is the default (or change the default to wrap), and mention endLine next to it.

B57

Widget.sizing is write-only

LowWhy: measured↑ index
Who
Anyone building screen UI with the MUI/NewUI widgets.
What
Reading Widget.sizing raises an error; it can only be assigned. sizingX reads back as an enum integer. A layout probe that walked the widget tree reading sizing therefore never found the card it was looking for.
When
2026-10-07, while rebuilding Ashlight's whole interface (round 21).
Where
MUI widget properties.
Why
The property has a setter but no getter, and this isn't documented.

Impact

Layout code and debugging tools that read sizes back fail, and the error doesn't say the property is write-only.

Mitigation

Keep the intended sizing in the game's own tables instead of reading it back.

Suggested fix

Add a getter, or document which widget properties are write-only.

B58

A disabled button is illegible

LowWhy: measured↑ index
Who
Anyone building screen UI with the MUI/NewUI widgets.
What
Setting the engine's enabled = false on a button tints it blue-grey and leaves its text nearly invisible.
When
2026-10-07, while rebuilding Ashlight's whole interface (round 21).
Where
MUI buttons (enabled).
Why
The engine applies its own disabled tint, regardless of the button's styling.

Impact

A disabled option can't be read, so players can't see what is unavailable or why.

Mitigation

The game never sets the engine's enabled; a disabled button is a style (dim outline, no handlers).

Suggested fix

Let the disabled look be styled, or make the default tint keep text readable.

B59

A clear background with a frame renders as a grey fill

LowWhy: measured↑ index
Who
Anyone building screen UI with the MUI/NewUI widgets.
What
A panel with a transparent background and a border is drawn with a default blue-grey fill instead of staying clear.
When
2026-10-07, while rebuilding Ashlight's whole interface (round 21).
Where
MUI panel backgrounds.
Why
A frame appears to force a default background colour when none is visible.

Impact

Outlined, see-through elements (unselected list rows, detail panes) show up as grey boxes.

Mitigation

Unselected rows and the detail pane have no frame at all; only the selected row is raised and framed.

Suggested fix

Respect a fully transparent background when a border is set.

B60

string.upper corrupted a multi-byte character

LowWhy: inferred↑ index
Who
Scripts that uppercase text containing non-ASCII characters (titles, names).
What
In testing, string.upper mangled a multi-byte em dash ("—") in a title.
When
2026-10-07, while rebuilding Ashlight's whole interface (round 21).
Where
Lua's string.upper in the engine.
Why
Inferred: string.upper works byte by byte under the server's C locale, which can alter bytes of UTF-8 sequences.

Impact

Corrupted characters in uppercase titles, which only appear for some strings.

Mitigation

Titles use an ASCII-only uppercase helper that leaves other bytes untouched.

Suggested fix

Run Lua under a locale whose toupper leaves bytes above 127 alone, or provide a UTF-8-aware upper.

B61

Generated icon art is unreadable below about 24 px

LowWhy: measured↑ index
Who
Games that use generated images as UI icons.
What
The 39 generated dark-fantasy icons turn into smudges when drawn smaller than about 24 px on a dark UI.
When
2026-10-07, while rebuilding Ashlight's whole interface (round 21).
Where
Generated image assets used in MUI icon slots.
Why
Painterly detail doesn't survive being scaled down to a few pixels.

Impact

Small HUD slots (resource rows, the loot feed, the ability bar) can't use the art directly.

Mitigation

The art is drawn only at 24 px and up; smaller slots use coloured shape tokens, and art under 48 px gets a thin ember outline.

Suggested fix

Offer an icon-generation mode that produces simple, high-contrast glyphs, or allow vector or atlas icons for small sizes.

Optimization result

Server fps with 24 enemies all chasing one player (5 s windows; idle baseline 59.5).

SDK controller
38.2
Ray mover
39.5
+ 5-part rig
40.6
+ engine decoupled
53.0
+ lean roots
54.7
Boss fight, 24
53.3

Measured limits

Server fps over a 4–5 s window after each step. Bars are scaled to 60 fps; anything under 30 fps counts as broken for a shared world.

Idle baseline
59.5
+300 objects
54.8
+1,000 objects
36.9
+200 in 1 step
9.7
10 NPCs
41.5
20 NPCs
30.4
40 NPCs
13.2
40 NPCs, game LOD
27.1
24 NPCs, game LOD
36.2
ProbeLoadResultVerdict
Server raycast1k / 10k / 50k rays≈24 µs per ray; 50k = 1.2 s stallHeld
RPC flood50k, then 500k50k queued in 72 ms; no visible impactHeld, but no backpressure
Tiny static edits5,000 in 1 frameAll accepted, 240 msHeld
Big editsSphere r 4→28Soft reject r 12–17, hard refusal r ≥24Held, guard worked
Character controller1 call≈299 µs on the serverBroke at scale (B1)
Networked writeChanged vs same value10.1 µs vs 0.1 µsFine; write only on change
Networked payload16 / 64 / 256 MBAll accepted; 3.57 s pingBroke (B5)
Lua heap256 MB → 3 GBNo cap; process 21.7 GBBroke (B6)
Client particles500 / 2k / 8k29.2 / 26.2 / 12.7 fpsBroke (B9)
Voxel op on a 0.2-scale partAdd plate 0.28 m / 0.15 m+114 ms / +33 ms frame timeExpensive (B29)
Voxel op on a 0.2-scale partPaint plate 0.28 m / remove sphere 0.24 m+17 ms / +64 ms frame timePaint is the cheap option
Any voxel op on a ~0.19-scale part1 op≥6.6 ms engine commit, 7–30 ms typicalExpensive (B29)
Same Add on a scale-1 objectPlate 0.28 m+0 msHeld
Bind a template rig part1 part≈60 ms LuaExpensive (B29)
Bind a small baked shell154–7,827 voxels40–358 ms, in one frameBroke (B42)
Bind a baked weapon shell89–96k voxels1.2–1.4 sBroke (B42)

Mitigation log

What the game did about each breakage, as of this final edition (project paused 2026-10-08).

#Mitigation in AshlightStatus
B1/B16/B17A ray-probed enemy mover with positions held in Lua, engine objects re-centred every 10 s, a 5-part enemy rig, hidden templates switched off, AI at 5 Hz near players and 2 Hz away, and a cap of 24 enemiesShipped: 53–56 fps worst case
B2Enemies spawned one per 0.3–0.4 s; boss bodies built across framesShipped
B3Settle waits after terrain clears, and a fall-catch that teleports players backShipped (workaround)
B4/B21Staging folders, single-write copies, compare-after-sync, one deployerProcess
B7Generation jobs queued at most 4 at a time, with failure tracking. 11 of 11 sound effects generated fineShipped
B8Builders that resume, idempotent rebuilds, and no Lua saves while generation callbacks are pendingProcess
B12Game state serialized as Lua-literal strings in a ledger; the terrain journal kept as its own stringShipped
B19/B20Terrain Keeper: full save only when the server is empty, plus a replayed edit journalShipped
B9Particle budgets per effect; no particles in the server's headless clientShipped
B23Event-queued key input in the player scriptShipped, verified live
B24Audio module re-indexes on asset sync and falls back to built-in soundsShipped
B26Server rigs pose at 4 Hz in the headless client; visual checks done in a visible browser client, or with screenshot --client <id> through a real client with lighting onProcess
B27Persisted timers moved off Time.time; now on the persistent game clock (B41)Shipped
B28Terrain reworks scheduled for an empty server; server-side ray audits; floating props found and grounded (a dead tree hung 35 m up, and an oil drum 1.3 m up after the Pit rim was reshaped)Process + fixed
ArenaPit rim reshaped into crags; stair access to all three high-ground shelves verified with a 0.5 m-step flood fill in both directionsVerified live
B29Pre-built enemy rig pool; spawn 320 ms → 4–20 msShipped, measured
B29 (follow-up)Bulwark rigs pooled the same way (previously about 300 ms per spawn); the Matron's 10 parts staged ahead of the fight, and her spawn measured at 10 ms liveShipped, measured
B28 (update)Phantom geometry also seen 2–10 m from the camera after about 600 hub edits; server rays confirm the plaza is clear. Hub and plaza dressing now follows the empty-server rule tooProcess
B23 (follow-up)Mouse clicks and UI keys are now event-queued too (attacks, build placement, menus)Shipped
B19 (follow-up)The Keeper's automatic empty-server save fired twice on its own: the journal was cleared and the terrain marked savedVerified live
"Odd walls"The 12 bare ruin shells were rebuilt as dressed ruined buildings (2 168 ops, 0 errors). The hub wall became a scavenged palisade, ash patches became flat paint, the sphere berm became a continuous boundary, and test structures were removed through the journalShipped; the edge was later rebuilt as faceted cliffs (B36)
B30Edge walls kept at least 2.9 m above the highest foothold; the Undercroft gated by a one-way door rather than a ledgeShipped
B31Stamped armour visuals reverted before players saw them, then rebuilt from live part measurements as shell stamping (B42); offline mocks must model scaled flat partsResolved
B32Deploys batched and made with nobody online where possible; adopting the pool after reload tried and revertedProcess
B19 (update)One automatic empty-server save measured at a 33.4 s main-thread stall; the Keeper saves non-journaled edits at most hourlyMeasured
B33Hit-stop by scaling each rig's update time; vignette from stepped translucent bandsShipped
B20 (update)Procedural props (oil drums) found empty after a restart; rebuild on boot when the VoxelData is empty, or save props as assetsFix proposed; not confirmed shipped before the pause
B33 (update)Hit-stop and directional damage vignette deployedShipped, verified server-side
B34Wait for the .zma or retry before attaching a new scriptProcess
B35Retry with backoff for fetch failures (read back state before retrying world-changing Lua); boss logic checked in offline harnesses and server-side probes; human playtests for feelProcess
B36Handedness normalised in the parallelepiped helper; north and east cliffs rebuilt (lowest crest N 0.5 → 6.7 m, E 0.0 → 5.7 m, 0 floating cells); plaza banners restoredShipped, verified live
B37Read-only ray audits (height maps, underside rays) after every procedural buildProcess
B36 (update)Guarded helper in every builder; homes 0/3 → 3/3 edge pieces with a boot-time rebuild; lair buttresses 0.7 → 5.8 m; Deep Arena crust cavities 0 → 1.9–2.4 m; armour confirmed unaffectedShipped, verified live
B29 (siege)Permanent Hollow pool of 14 for the siege: its warning phase holds 59 fps with a worst frame of 31 ms (was 48.5 fps, 140 ms)Shipped, measured
B38None possible in game codeReported
B39Watched for a plateau; the atlas capped (see update)Superseded below
B29 (stress test)No cold builds mid-fight, throttled pool rebuild, jittered AI think, lighter static jobs: steady 50–51 → 56.3 fps, worst frame 84–94 → 29 ms. Ramp-up spikes (p99 341 ms) traced and fixed in rounds 12–13 (see below)Fixed later, measured
B39 (update)Atlas confirmed capped at 5,000 MB; residual growth of ~7 MB/min unexplained at the pauseOpen at the pause
B40Parked rigs activated briefly, between fights, while being dressedShipped
B29 (resolved)Spawn spikes traced to voxel adds on scaled parts; crust baked into 3 variant assets, chips painted, pools paced. Ramp p99 508 → 60.9 ms, worst 893 → 73 ms; steady worst 84–94 → 51 msShipped, measured
B29 (forging)Forging armour on a player rig: 43.5 fps, worst 83 ms for ~6 s; baked tiers tried and reverted (B42). Now live 1-op/frame stamping onto shells: 47 fps, worst 48–52 ms over ~35–40 sAcceptable
B29 (round 14)Ramp p99 38.6 ms, worst 73 ms; steady 54.3 fps, p99 30.7 msMeasured
B42Baked-armour character patch reverted (58.2 fps, worst 28 ms after); assets kept for relinkingReverted
B42 (update)Freeze traced to the in-script bake code, now removed: 0 freezes in ~7,000 frames with and without templates. Baked binds still 100–500 ms, so armour shells were tried nextSuperseded below
B42 (shells)Armour shells measured at 40–360 ms per bind; shell code re-introduced the periodic freeze and was reverted; 111 obsolete variants (329 MB) deleted. Next: live 1-op/frame stamping onto shellsReverted
B42 (round 5d)No binds or SaveAs for armour; idle shows 0 freezes over 100 ms in 6,957 frames; 75 shell templates (60 MB) deleted. Freeze correlates with heavy SaveAs use (unproven)Shipped, measured
B44Restart-proof state: persistent clock, immediate ledger saves, boot repair passes; all survived the 01:10 restartProcess
B29 (round 16)Hollow, Body and Beacon components inactive, driven by one manager: steady 54.7 fps, worst 32 ms, no frames over 33 ms; script time 8.66 → 4.15 msShipped, measured
B43None possible in game code; fewer active objectsReported
B41Persistent game clock in the ledger; immediate ledger save after one-shot repairs; boot-time home repairShipped
B44 (update)Fourth restart during background Hollow dressing (25.1 fps, worst 596 ms beforehand); dressing reverted (59.7 fps, worst 17 ms); continuous stamping on scaled parts shelvedShelved
B45Restart sync after every server restart; confirm "pushed" lines per deployProcess
B46Joins held until the chat name arrives (up to 20 s); records refused for guest names; playtesters sign inShipped, verified
B47Vectors duck-typed on .x/.y/.z; Reach smoke test 16/16 including the ping broadcastShipped, verified
B44 (#5, #6)Restarts at 07:22 (playtester walking to the hub) and 07:58 (test client); orphaned syncs stoppedOpen (cause unknown)
B29 (rounds 17–18)No new rigs while 18+ Hollows are out: worst case 56 fps, worst frame 36–37 ms (was 64–102 ms)Shipped, measured
B48Long-running sync stopped; push-only deploy script on the CLI's files clientProcess
B49Close clients gracefullyProcess
B50Rest transform published in a networked table; client waits for itShipped
B44 (#7)Rig-op budget (4/s, none near players), zero-op crust chips, Warden rebuild never stamps within 80 m; siege smoke rig ops 34 → 0Shipped, measured
B51Boot-time terrain relink (repairTerrainLink); verified on the play serverShipped, verified
B51 (update)A publish reported finished while delivering nothing; retried, then verified the play server's code, terrain and music by probeProcess
B52Scenes backed up; live world saved as InitScene with its own terrain copyFixed by hand
B53Case-insensitive lookup with a v1 fallbackShipped
B54Check durations; regenerate short takesProcess
B55Plain copies for each networked tableShipped
B56Frame helper defaults to overflow = "Wrap"; explicit endLine elsewhereShipped
B57Sizes kept in game tables, never read backShipped
B58Disabled buttons are a style, not the engine flagShipped
B59No frame on clear elements; only the selected row is framedShipped
B60ASCII-only uppercase helper for titlesShipped
B61Art at 24 px and up; coloured shape tokens belowShipped
B5No game-side cap exists; networked state kept small and written only on changeAvoided
B6None possible in game code; no unbounded tables, and long probes kept shortAvoided
B10None in game codeOpen (platform)
B11The game replicates the transforms it needs itself, through networked tables and RPCs (see also B50)Shipped
B13Large builds split into many small opsProcess
B14Known Editor Tools boot errors ignored when checking engine errorsProcess
B15Symbols checked against the running build before useProcess
B18Characters stay server-side rigs, pooled (B29)Worked around
B22Client tuning based on code-path costs, not a hidden tab's fpsProcess
B25A tunable lift for the vitals panel (AshUI.VITALS_LIFT); off by defaultShipped

What held up

Recovery

Every test recovered fully after cleanup. Server fps went back to 59.5–59.8, and nothing crashed the host.

Edit guard

Oversized voxel edits were refused before they could stall the frame, with an actionable message.

Raycasts and RPCs

Fast and steady at volumes far beyond what gameplay needs.

Custom rendering

A full-screen light pass rewritten in ssao2.fs hot-reloaded live and carried game state through the fog uniforms.

Persistence

Player homes, fuel levels and quest state survived Lua reloads through a ledger serialized as Lua-literal strings.

Live building

A 160 m map, five segmented character rigs and ten tutorial steps were all built through the CLI without opening the editor.

Runtime voxel detail on characters

Worn armour is stamped as voxels straight into each player's own copies of the rig parts, so it adds no objects. Each plate is an oriented box built from the part's transform at the moment its edit runs, which keeps it correct on a moving character. Verified on a live dummy at tiers 1 and 3. Caveat (B31): measure the live rig; offline mocks got the part scale wrong.

Automated server-side playtests

A smoke test drives a scripted newcomer through the whole tutorial on the live server, using the game's real server calls. It caught a blocker every offline mock had missed: the shrine's interaction range overlapped the altar's, so no newcomer could choose a calling. It also caught tutorial slots that were never rebuilt. The fixed tutorial passes 14 of 14 checks. This partly works around B35: the server side can be tested automatically, even though the client can't.

Inactive script components

Setting a script component inactive stops the engine calling its Update (0 µs), while its networked writes still replicate, it still sends and receives RPCs, and its client copies keep running. Deleting the Update function doesn't help: the call still costs about 29 µs. Driving many objects from one manager this way halved game script time (8.66 → 4.15 ms per frame).

Baking runtime detail into assets

Stamping a template copy once and calling VoxelData:SaveAs stores the stamps in a new asset. Bound copies measured 22–26k voxels against 15.9k plain, so the detail survives, and spawning from the baked variants costs no voxel ops. It is now used live for Hollow crusts. Cosmetic detail on scaled rigs should be baked, not stamped at runtime, but baked variants are large (B42), so keep their number small.

Final autopsy: building through the CLI and MCP

Written by the coding agent (Claude) that built Ashlight, at the point the project was paused (2026-10-08). This is the builder's view of the tooling itself: the virtualmatter CLI, its MCP server, the in-world SDK surfaces those tools reach, and the publish path. Engine and runtime breakages stay in the index above. This section is about how quickly an agent can turn "the user wants X" into a verified, playable X.

Scale of the work

What was genuinely helpful

  1. run-lua was the backbone of everything, on the server and on a client (--target client). Most verification, profiling and test automation ran through it: read-only probes of live state; scripted smoke tests that drive a fake player through the tutorial, combat and a full siege using the game's real server functions; client-side UI probes; and reading engine profiler timers on a real client. Its clear "504 may still be running, do not resend" contract prevented double-applied world changes.
  2. screenshot --client <id> captured exactly what a real, lit client sees, UI included. This was the only reliable way to judge lighting, UI layout and theme. It caught clipped labels, a stray HUD plate and illegible icons that the mock tests passed.
  3. errors. A cheap health check after every deploy.
  4. open, the native client. A real client the agent could launch, sign in and probe. Engine timers read through it (AE:GetLogTimers/GetLogTime) found the true frame costs: shadows were 12 of 42 ms, and the SDK particle updater about 50 µs per particle. That was a 2× fps gain the playtester felt.
  5. Hot reload on save. Iteration measured in seconds, with no build step.
  6. In-engine generation through Fal. About 90 sound effects and music tracks and 39 UI icons, made without leaving the world.
  7. apiEmmyAtomontage.lua plus the skill packs. One greppable file of real signatures (for example Server:DisconnectClient, GetLogTimers, SaveSceneAs) answered most "does this exist?" questions instantly.
  8. The files API underneath the CLI (FilesClient: list with etags, put, get, delete). Once wrapped into a push-only deploy, it was fast and safe.

What was excess or got in the way

  1. sync is the wrong default for an agent on a real world (B4, B21, B45, B48). It is a two-way watcher with no ignore list.
    • It pulled the 370 MB terrain on every server save, including the engine's half-written .am~ temp file.
    • It reported false conflicts on every push.
    • It pushed partial .vmdownload fragments into the world.
    • It re-pushed unchanged scripts, each of which Lua-resets the world.
    • Stopping it left the node child running, so three syncs once ran at once.
    Push-only plus explicit pulls is all an agent needs.
  2. Every .lua save resets every script (B8, B27, B32). A one-line deploy rebuilds the whole world state: players re-join, native clients get new random names, rigs rebuild, in-flight async work is lost. It also forced "freeze deploys while generation runs" choreography.
  3. The default screenshot client is misleading for visual checks (B26). SSAO off, no game lighting, low LOD. Every lighting judgement made from it was wrong until --client was used.
  4. The MCP write_file needs the whole file in the request. The game's scripts are 50–460 KB, so every MCP deploy would cost context, and there is no patch, diff or "push this local file" tool. The CLI files API had to be wrapped instead.
  5. AGENTS.md is very long. It repeats the same rules many times, such as "don't use the website agent". It costs context every session, while the facts that actually bit (the B-entries here) were missing from it.
  6. Ambiguous long calls. A large save makes run-lua time out (504) with no job handle to collect the result later, so every heavy operation needed hand-made polling.

What's missing, ranked by how much time it would have saved

  1. A reliable publish command, virtualmatter publish, with accurate status (B51, B52). "Update play servers" restarted the play server before the 390 MB terrain arrived. The server then booted the old starter scene and saved over the published one. It also once reported "finished" while delivering nothing, the per-server status sat at "queued" long after sync, and the Restart button did nothing. Needed: restart only after every file lands; name the scene the play server will boot; expose an API the agent can call. Publishing was only possible by driving the website in the maker's signed-in browser.
  2. Crash reasons and server logs. There were 7 unexplained server restarts (B44), one mid-boss-fight in front of the maker. Needed: virtualmatter logs; the last N stderr lines; restart reason, exit code, OOM or watchdog; memory over time.
  3. Push-only deploy and an ignore list. virtualmatter push <files> / pull <files>, a .vmignore that excludes *.am and temp files by default, and sync that kills its child on exit.
  4. Deploy without a full Lua reset. Module-level hot reload, or a "reload only this script" option. Plus a guard that defers a reload while engine-side async jobs (Fal, voxel jobs) are pending.
  5. Client automation for playtests (B35). Spawn a headless or managed test client, inject input (keys, mouse, movement) and read its HUD. That would turn "ask the maker to playtest" into "the agent playtests first". Test clients should not count as real players (ghosts: B49) or create saved characters.
  6. MCP tools sized for code. patch_file (anchored replace or unified diff), push_local_file(path), run_lua against a play framing (read-only), and screenshot with client_id. All of these existed only as hand-written CLI wrappers.
  7. A profiler endpoint. One call that returns per-script and engine timers averaged over N seconds, on the server or a client. The agent rebuilt this from GetLogTimers and SetProfileScriptsUpdate.
  8. Identity and world state in world_info: a stable per-user id at clientConnected (B46); the current scene file, its terrain asset, the dirty flag and last save times; and which scene play servers boot.
  9. Generation results that report what was made. The final asset name after normalisation (B53), the real duration of generated audio (B54), and a job-status API.
  10. A branch or sandbox world for smoke tests. Every smoke test ran on the live world and needed careful teardown of fake players, records, Omens and fuel. A cheap clone would make destructive tests safe.
  11. Structured errors with a cursor. errors --since <cursor> as plain JSON. Today it is double-encoded JSON that repeats old errors.

Tools the agent had to build to fill the gaps

These all live in the session scratchpad and are reusable by the platform team:

Bottom line

The engine connection (run-lua, errors, screenshot --client, open) is excellent: an agent can see, measure and test almost anything in a running world. What slowed this project down was the plumbing around it:

Fix those four and an agent could go from feedback to a verified, published fix in minutes instead of hours.

Method