Troubleshooting
The order matters. Work through these steps and stop at the first one that names the cause.
1. Read the errors first
Most "mysteries" are one hidden error string. A throw swallowed by a pcall,
an awake() that errored and disabled its component, a data value violating
its contract, an unresolved require — each logs an error or warning the
moment it happens, whether or not anything surfaced in your execute result.
The problems tool in the debug toolbox is the first thing to reach for
whenever something misbehaves. It returns the errors and warnings logged since
your last call, oldest first — so the root-cause error reads ahead of the
cascade it triggered — and advances a read-cursor so repeated calls report only
what is new. Narrow it to errors with level, or ask for all to get a
cursor-free overview of the recent problems.
The errors tool in the diagnostics toolbox collects the per-entity
component errors in the scene — everything components have raised through
reportError plus engine-origin failures attached to entities (a broken model
URL, a failing asset field). Give it an entity name or id to narrow to one.
Component errors apply through the mutation queue, so an error reported this frame is visible on the next call.
Behind both sits the full log ring — every level, chronological,
searchable. The logs toolbox reads it:
- search — the general query. Narrows by severity, subsystem, substring or regex, the script or entity that logged a line, and a time window. It can also return the lines either side of each match — what led up to a failure is made of lines a filter rejects, so nothing else will show you them — or collapse a repeating message into one row with a count, so a per-frame error stops burying everything logged around it.
- tail — the most recent lines of any level, oldest first.
- errors — recent errors and warnings, newest first. A snapshot, where problems is a drain: that one answers what is new, this one what is there.
- summary — how many lines, of what severity, from which subsystems and entities, and which messages repeat. The shape of the noise before you go looking inside it, and the cheapest check for whether anything went wrong at all.
- clear — drops what is buffered; lifetime counts survive.
The same ring is also readable as plain files, which is the shorter reach
when you already know the string you are after: /zero/runtime/logs/engine
is every level, /zero/runtime/logs/errors the ERROR-and-above subset, and
/zero/runtime/logs/script what scripts logged. They read like any other
file — cat and grep work on them, and they are mounts on the engine's own
filesystem rather than paths on a host disk — so a search for a message you can
already name needs no query at all. Two
things separate them from the tools: engine and errors show the most
recent 500 lines rather than reaching the whole ring, and they include the
record of your own tool calls, which the tools leave out by default — so
grepping for a marker string can return the call that went looking for it.
A line carries the script and entity that logged it, so asking for one entity's output is a filter rather than a guess at what its messages look like. summary also reports a cursor: read it before an action, hand it back to search afterwards, and you see only what that action logged.
A result that came back carrying __unreadable = true where you expected a
position or a rotation is a transform value read after its entity was
despawned. The object names the entity, which value it was, and the refusal in
reason — the entities guide's A proxy whose entity is gone has the full
shape.
2. Inspect the state
When the logs are clean, the next question is what does the engine think the state is — not what your code intended it to be.
debug.inspect reports entity component state for every entity matching
a target — an id, a name (names are not unique; a name resolves to every
match, and globs like enemy_* work), an entity proxy, or an array of them:
It reports both sides by default; include narrows to script components or to
native ECS ones, and types narrows to a single component type across every
entity the target matched.
Each match reports script (every script component's serialized public
data) and ecs (every native component's field values — Transform, Light,
Model, ...). Comparing the two sides catches a whole class of bugs where a
script component's data and the native state it drives have drifted apart.
To react to state rather than read it, gameplay code uses the live surface:
entity(id).component.get(type) returns the component's live public proxy,
and getAll() returns { type, instance?, data } records where data is
that same live proxy — writes through it hit the running component. The
serialized form (what debug.inspect shows, and what persistence saves)
comes from the component_snapshot module.
To see the structure itself, scene.tree renders the hierarchy; to find
entities, scene.findAll filters by name, pattern, root status, or depth, and
scene.findByComponent finds every entity carrying a component.
3. See the invisible
Plenty of state has no pixels: bounds, camera frustums, skeleton bones, physics colliders, meshless entities. The ambient debug overlay draws them:
- set — show or hide one category, or
allof them at once. - scope — focus every category on named entities.
- playSession — keep the overlay visible in play mode.
- state — what draws right now.
Categories: bounds, frustum, bones, colliders, icons, wireframe.
The overlay is ambient — no per-entity setup — and scope keeps a busy scene
readable by drawing only for the entities under investigation. The play-mode
session is in-memory only and never saved, so bones and colliders stay
visible on a running, animating scene without touching the world's source.
A capture proves what the overlay shows: take one after enabling a category and read the actual pixels.
For collision specifically, the overlay draws wireframe outlines and
capture(pass = "physics") draws the collider set as solid shaded surfaces —
easier to read at a glance, and it carries what each collider takes part in:
blue static, orange dynamic, green kinematic, magenta and translucent for a
sensor, with lightness separating individual objects inside a family.
capture { pass = "physics" } -- the colliders alone, on black
capture { pass = "physics_context" } -- the same over the scene's meshes in dim grey
That is the first thing to take when something behaves wrongly rather than
looks wrongly: falling through a surface, an invisible wall, a body launched on
the first frame of play, a trigger firing in the wrong place. A gap in an
otherwise continuous run of blue is the tile something falls through; two
colliders drawn deeply inside each other are bodies the solver will fire apart.
Physics.colliderManifest() gives the same reading as data when you need the
entity id rather than the picture.
4. Ask the renderer the right question
capture with the default final pass answers "does it look right" —
aesthetics only. For concrete debugging, pick the diagnostic pass that
matches the question, because each renders a known encoding whose answer is
unambiguous in one frame:
- Is the geometry there / facing the right way? →
pass = "normal" - Is it positioned / layered where I think? →
pass = "depth" - Is it actually moving? →
pass = "motion_vectors"(static = black) - Is the material what I set? →
albedo,roughness,metallic,emissive - Is the light reaching it? →
pass = "shadow"
Anything that moves or animates needs a time collage — mode = "collage" with
duration — because a single frame of motion proves nothing.
A collage is a grid, and what varies across its cells is the argument you pass:
duration for a cell per sample over time, passes for a cell per render pass,
viewpoints for a cell per named view of one subject, setups for a cell per
whole camera set-up — a shot list, each entry aimed the way a single capture is.
setups and viewpoints both say where the camera stands, so a request names
one of them; either crosses with passes. passes = { "final", "albedo", "normal", "depth" } puts one frame under four questions at once, which is how a
material problem separates from a geometry one without four calls to compare
from memory.
5. Ask about a specific object
Whether an object is built right is a different question from whether the scene looks right, and it has its own arguments:
viewpointnames where the camera stands:front,back,left,right,top,bottom,iso. It comes withbasis, which says whether the name is measured against the subject's own axes ("local"—frontis the side it faces however it is turned) or the world's ("world"). With a subject there is no default, because for anything rotated those are different pictures.projection = "orthographic"covers a fixed world-space height at every distance, so parallel edges stay parallel and two equal-size objects at different depths cover equal pixels. Perspective convergence hides both, which is why a side read under it proves less than it appears to.isolate = truedraws the subject without the other geometry, so a prop behind a wall can be looked at where it stands. It changes only that: lighting and sky are untouched, and excluded geometry still casts shadows onto the subject. Reach for it on any object-focused shot: naming anentityfits the FRAME to that subject but does not decide what is drawn in it, and a scene's default player spawn sits at the world origin — exactly where a new prop tends to be built, and large enough to hide it.
Combined, they make the shot that answers "is this asset correct" in one call — its six sides, each an orthographic elevation, nothing else in frame:
capture { entity = "crate", mode = "collage", viewpoints = "sides",
basis = "local", projection = "orthographic", isolate = true }
Every cell comes back labelled with the viewpoint or pass that produced it, so a grid is read from the response rather than guessed from the pictures. The capture tool's schema documents every pass and parameter.
Two rules keep captures honest. Screenshots are never same-frame — seconds
pass between an execute and a capture, so if the effect should be there
and isn't, it's broken, not "deferred". And a capture is data: read the
pixels against what the operation should have produced, don't glance and
declare.
6. Ask what the editor action actually committed
"I dragged it and it didn't go where I put it", "I pressed the menu item and
nothing happened", "the delete key removed something else" — every editor action
publishes a record when it closes, and the editor toolbox's observe tool reads them — every action's last
record.
The drag record separates three quantities a single "it didn't move" conflates:
pointerAsked (where the pointer put it), applied (what the gizmo handed the
engine after snapping quantised it), and each entity's after — the transform
the engine holds, read back from the engine rather than recomputed. Each
entity row carries before / requested / after and, when it is not a clean
commit, one reason: writeDiverged when the engine committed something else,
writeRefused with the error when the write raised, entityMissing when it was
despawned mid-drag. The whole drag reads cancelled / userCancelled for an Esc
and cancelled / pivotLost when the selection emptied under it — three
terminal states that otherwise all look like "nothing happened". A press that
landed on a handle and began no drag at all is the fourth: it publishes a grab
record naming pointerBlocked — the UI layer held pointer focus, so the press
never reached the handle.
The same read answers a menu action that did nothing: a command's record tells
commandMissing from commandDisabled from predicateRaised from bodyRaised,
and commands.run returns that reason to its caller as well as logging the
error. delete and duplicate records carry the ids removed and created, so
the id of a fresh copy comes from the return value rather than the log.
The editor's own per-frame cost is named alongside it —
script.editor.gizmo.tick, script.editor.viewport.select,
script.editor.selection.highlight and script.editor.panel.<id> in
profiler.stats() — so a slow editor is attributable to the piece that is slow.
7. Let the static layer catch it before it runs
Every execute runs a static check first — unknown globals, unknown members
(with did-you-mean), wrong argument counts, unresolved requires — and in
strict mode an error-severity diagnostic blocks execution entirely, so broken
code never corrupts state. Read the diagnostics field on the response
instead of silencing it.
The same layer checks saved content: lsp.check("<identity>") re-checks one
module, lsp.checkAll({ scope = "user" }) sweeps everything world-authored
and lists every path:line — message. A component that "does nothing" often
simply failed its check and never loaded.
One line is meant to be wrong: a test that calls an API incorrectly to
assert it raises. Declare that with --!expect-error [code,...] on the line
above, and the errors it names are consumed. The declaration is checked in
both directions — when the guarded line checks clean, expect-error-unfulfilled
reports at the directive, so an API that stops rejecting the input surfaces
at the assertion that outlived it.
The method
- Reproduce first. A bug you can't trigger on demand isn't understood yet; fixes against an un-reproduced bug are guesses.
- Verify with data AND pixels. A correct-looking return value with a wrong capture (or the reverse) means the investigation isn't done.
- Change one thing, re-test twice. First-time success is meaningless; the second call catches state leaked by the first.
- Never restart the engine to make a symptom go away. If it only works fresh, it is broken — the restart just hid the evidence.
- When the cause is an engine defect, file it — with the reproduction, the expectation, and the observed difference — rather than working around it in content. Workarounds outlive the bug and become bugs themselves.
Related: core/scripting-and-tasks (where state lives, task errors),
core/components (lifecycle, reportError), core/discovering (finding the
right API surface), topics/rendering (the pass pipeline behind captures).