Devtools API
The engine exposes everything the debugger draws, and everything an agent that cannot look needs, on one store: useDevtools. Nothing here draws. @spawnite/devtools reads the store for its overlay, and an MCP tool or a test reads it outside React through useDevtools.getState(), as every engine store is read. The following pages continue this one:
The world the store acts on is bound by whoever runs it. Inside Game, the frame loop binds the running world. A test or an agent binds a headless game from the harness with attachDevtools(game), and the return unbinds it.
The tree
Section titled “The tree”tree is the scene as the developer wrote it: the scene by its name, then each game component by the name it was written under, with its entity id beside it, and its behaviours as leaves with the props they were given. A Coin that renders an Entity with Spin, Bob and Pickup reads as Coin #12 with three leaves under it. An Entity written straight into the scene reads as Entity. World, CharacterSpawn and Camera have rows of their own. There are no invented groups.
A scene’s HUD sits in the same tree, as Unity lists UI under its Canvas, Godot lists Control nodes in the Scene dock and Roblox lists GuiObjects under a ScreenGui. A Hud has a row where it sits in the JSX: under the scene’s row, or under the entity whose render holds it. Each Panel inside it has a row under the Hud’s, and each Button a row under the Panel or Hud it stands in, named by its text, as Button: Play, or by its label where it shows one icon. A game’s own component inside a Hud adds no row. These rows are of kind hud, with an id such as hud:3, and no entity. Headless, a Hud mounts nothing, so its own row stands with no rows under it. The Entities tab draws them under ▭. A press on one selects it, as a press on an entity’s row does, from the keyboard and on a phone’s sheet too. While a Panel’s or a Button’s row is hovered or selected, the outline frames its element on the screen, as Unity draws a UI element’s rect, Godot a Control’s and Roblox a GuiObject’s selection box. The inspector shows the row’s name, its kind and the props the game wrote on the component, such as a Panel’s slot and variant or a Button’s label, on a card that takes no edit, with a function as function and no Send to agent. useHudRows holds each such row’s element and props by row id, beside the tree, so the tree stays plain data that spawnite play prints. A Hud’s own row has no element and no props, so it frames nothing, as a Roblox ScreenGui and a Godot CanvasLayer draw no selection box.
Each node carries id, name, kind, an optional entityId and props, and children. The name comes from the React component tree, so it is the developer’s own name for the thing and needs no registration. Only the scene that runs has a row, whose id sceneRowId(name) gives, such as scene:lobby.
The World’s scatter reads as one Scatter group under the World’s row, with a row per kind in it, Tree Round ×212, with the kind’s count. The Scatter row’s id starts with the World’s own, so two Worlds mounted at once each keep their own: under the World whose ground is entity 3 it is 3:scatter, and a kind’s row is 3:scatter:treeRound. A tree, a bush, a rock or a tuft has no row until something asks for one: 3:scatter:treeRound:12 is the round tree at index 12 in its kind’s batch, and findNode makes its row on the lookup, named as its kind’s row is, with its index and the reason it takes no edit. A meadow of two thousand tufts adds nothing to the tree. An InstancedModel or a TrackScatter registers one such counted row for its model under its parent’s row, named for the model, with an id of the model’s name and a React id so two of one model stay apart; its copies’ rows are made the same way by index. The kinds’ rows and the models’ come with the drawn batches, so headless there are none. Unreal selects foliage one instance at a time the same way; Unity’s terrain trees and Godot’s MultiMesh select only the whole batch.
Selection and pick
Section titled “Selection and pick”selectedIds holds every selected node in the order picked, and selectedId is the last of them, the one the inspector shows. select(id) makes that node the whole selection, and toggleSelect(id) adds it or takes it out. pickEntity(world, raycaster) finds the entity under a ray, walking the hit up to the entity whose object it belongs to, and passing through a hidden one. pickRow(x, y) casts that ray from a point of the canvas, given from -1 to 1 across and up, through the camera the game draws with, and returns the row of the entity it meets, or of the instanced model’s copy it meets. That camera is the one the outline projects through, so a camera with no orbit picks too, as a track’s ChaseCamera does; a headless game, which draws nothing, picks through the mounted orbit’s camera. pickRowHit(x, y) returns that row with the world point where the ray meets it. Where the ray meets an instanced mesh, the pick names the mesh and the instance’s index, and the row is that instance’s when a row under the entity’s counts the mesh’s instances, as the scatter’s kinds do. The scatter’s batches answer the ray through the ground entity, which holds them on ScatterRefTrait beside its own mesh; an instanced model’s answer it through the group its row holds, which no entity owns. The overlay passes the row to hover as the pointer moves over the game and to select on a click, or to toggleSelect on a ⇧ or ⌘ click. On a right-click, it opens the context menu with the point beneath the entity’s name.
The outline
Section titled “The outline”hoveredId and hover(id) hold the row under the pointer, beside the selection. While editing is on, the frame loop writes screenRects, keyed by row id, for the hovered entity and every selected one: where each stands on screen, in pixels from the canvas’s corner, which is its model’s bounds projected through the camera after the camera has moved for the frame. An instance’s rect is its kind’s geometry bounds moved by its instance’s matrix. An entity with nothing drawn has no rect, a row that is no entity’s, a behaviour’s or a group’s, has none, and headless no entity has one. A Panel’s or a Button’s HUD row has its element’s box on the page, read each frame, so the frame follows the element as it moves or resizes. The loop writes the store only when a rect changed, so a still scene wakes nobody. describe(id) carries the rect too, so a playtest driving the page knows where to click the entity.
Unity projects a renderer’s bounds to the screen the same way; Roblox, Unreal and Godot adorn the selection in 3D, which an overlay in the DOM cannot. Cost per frame: nothing with edit mode off; with it on, two bounding boxes and sixteen projections at most, a bounding box for an instance being its kind’s moved by one matrix read, and one layout read for each hovered or selected HUD row.
Pause, step and speed
Section titled “Pause, step and speed”pause() holds the world still and resume() lets it run. A page opened with ?hold in its address starts paused, before the world’s first step, and the playtest opens its page that way, so two fresh playtests stepped alike show the same frame. stepFrame() advances exactly one fixed step, pausing the world first when it runs, which is the period key on the top bar. setSpeed(speed) scales each frame’s time, 0.25, 1 or 2 on the top bar: the steps and every view’s useFrame alike, so a game’s own animations and particles slow with the world. The map’s own motion runs on the effects’ clock instead: the grass’s wind, the sea’s waves and foam, the water material’s waves, the ground’s flowing materials and wet ripples, and the sky’s drifting clouds. A game’s own Shader keeps the world’s clock, since it may show gameplay time. The pause holds it as it holds the world’s clock, and the speed leaves it at the wall clock’s pace, as the World page says. A modal’s pause is separate: it belongs to the modal stack, and holds the steps and every view’s clock. The devtools’ pause holds the steps, the day clock that moves with them, and the canvas’s clock. A view’s useFrame gets no time while the world is held and one step’s time, 1/60 s, in the frame that steps it, and the clock’s elapsed time holds with it, so a game’s own animations and effects stand still with the world and move with each step, as Unity’s zero time scale holds them. The camera’s controls, the free camera and any view on useUnscaledFrame keep the wall clock, so a drag still turns the view of a held or slowed world and the free camera flies at its own speed. On a room’s replica the pause, the step and the speed do nothing, since the room runs on, and every view keeps the wall clock. spawnite play pause holds the room itself and then writes the store’s hold on the page: the page’s own steps and every view’s clock then follow the store’s paused, owed steps and speed as a world without a room does, while the room’s stream still lands. A frame takes the steps owed, as many as a frame’s longest time holds, 0.1 s: the overlay owes one at a time, and a tool that owes a room’s hundred steps at once runs six a frame.
A paused world with edit mode off draws only when something changes, so an idle playtest leaves the GPU alone: a step, a change to the store, a file that finishes loading and a capture each draw one frame, and one frame a second catches the rest, such as a hot edit. Edit mode and a room’s world draw every frame. Roblox, Unity and Godot keep drawing while the game is paused. Unreal’s editor viewport with Realtime off, Godot’s low processor mode and PlayCanvas’s autoRender = false draw only on a change, as this does.
stepUntil({ until, steps }) pauses the world and takes one fixed step at a time until the expression holds or the steps run out, and returns held, the steps taken and the value the expression last returned. The expression reads the world’s entities as the dump holds them, count(trait), has(id, trait), player() and the steps taken, and the engine’s state as camera, loop, cursor and room, which a headless world leaves undefined. walkTo({ destination, steps }) sends the player’s character walking along the navmesh and steps until the character reaches the route’s end, and returns arrived, the steps taken and where the character stands, at. Both stop on the step on which a pausing modal opens, such as a round’s end, because the loop takes no step while it is open, and name that modal by its title in modal. The step owed when the modal opened is dropped, so closing the modal takes no step. A loop that takes no owed step within one second, with no modal open, is not running, and the call fails saying so.
The frame split
Section titled “The frame split”setFrameTiming(true) has the loop time each frame, and frameSplit holds where the last 60 frames went, written twice a second: each part of the CPU’s frame, then the CPU’s whole, the GPU’s and the frame’s, each by its mean and its worst, so a hitch shows as a worst rather than vanishing into the mean. The overlay turns it on while it is mounted, edit mode or not, so an agent driving the page reads frameSplit without the recording’s cost in the numbers. The MCP’s read_frame_split prints the split as the last playtest call that stepped the world left it. The top bar shows the CPU’s and the GPU’s means beside the frame’s, and the Frame tab lists every line, with the last frame’s draw calls and triangles beside the means and, in a room, the room’s own work per step, the server tick, which the room sends with each answer to a ping.
The parts are the loop’s own, as the frame page lists them:
step: every fixed step the frame bought, and in a room the moves sent with them.pose and animation: the character’s pose off the step, then her clips, her springs and their colliders.transforms and camera: the transforms drawn between the last two steps, then the camera.render: the time inside the renderer’s render calls, which is the shadow map, the scene and each of a composer’s passes, on the JavaScript side.other: the rest of the CPU’s frame, which is the loop’s input and stores, the otheruseFramesubscribers, a composer’s work between its passes and React Three Fiber itself.
The CPU’s frame runs from React Three Fiber’s before-effect to its after-effect, so React’s scheduled work, layout and paint fall outside it. The GPU’s time comes from EXT_disjoint_timer_query_webgl2, from the frame’s first render call to its end, which Chrome on the desktop has and Safari and most phones have not; there, the line reads inferred gpu and holds each frame less its CPU, which is the wait on the GPU or on vsync together with any main-thread work outside the loop. On a page opened with ?profile the line reads inferred gpu too: spawnite play profile times the GPU itself there, and two timers on one context end each other’s queries. With vsync on the timer takes in the wait for the swap too, so read the GPU’s cost with it off. The CPU and the GPU work at once, so the two do not add up to the frame. Each line keeps its own last 60 samples, and a GPU result lands a few frames after its frame. A frame spent in a hidden tab reads as one long frame and ages out.
Unity’s profiler shows CPU and GPU time as bars against the target frame time, Unreal’s stat unit shows game, draw and GPU, Godot’s monitors split CPU and GPU time, Roblox’s MicroProfiler shows each engine task per frame, and PlayCanvas’s MiniStats shows CPU, GPU and frame time; this is the same split over the loop’s own parts. Cost per frame, while mounted: a dozen clock reads and one timer query.
Panels
Section titled “Panels”registerPanel({ id, title, icon, order, component }) adds a game’s own inspector as one section of the rail, and the return removes it. id defaults to the title, icon takes a component such as one of lucide’s, and order places it among the game’s sections, lowest first. panels lists them. useLayout from @spawnite/devtools holds where the sidebar stands, which the browser keeps: openSection(id) opens a section, a SectionId or a panel’s panel: and its id, as a test or a story does before it reads one.
Playtest actions
Section titled “Playtest actions”A game’s playtest panel runs steps a tester wants on demand: a quick start past the title, damage off, a wave now. <Devtools playtestActions={actions} /> names those same steps for an agent, each a PlaytestAction from @spawnite/devtools, { name, run }, so spawnite play actions lists them and spawnite play action <name> runs one by name, whatever its case, without a chain of set and step calls. run returns a line the cli prints, such as No damage on., and throws to refuse, with why, where the panel’s button would be disabled. The panel’s buttons call the same functions, so the two never drift apart: Depthfield’s are the example. Unreal runs a cheat by name from its console, the functions a CheatManager marks Exec; Unity, Godot and Roblox leave it to each game’s own debug menu. An agent drives the game by commands, so a name it can list beats a button it cannot press. Cost per frame: none.
A panel the game opens on before play, such as a character creator, gets an action that closes it as its button does, so an agent leaves it with spawnite play action rather than a click found by its text: Hack and Slash’s Done is the example.
A game that mounts <Devtools /> only behind a parameter of its address, such as ?debug, shows the session no scene until the page carries it: spawnite play start --query debug adds the parameters to the address of each page the session opens, and --query 'map=town&debug' adds several. The session’s own parameters, such as room and hold, are set over them.
pinnedIds holds the rows whose panel stands over their entity in the world, in the order pinned. pin(id) adds a row once, unpin(id) takes it out, and a row that leaves the tree drops its pin. The pins last through a hot reload, which runs a game’s modules again but not the store’s, and go on a page load. worldPanels, off to start, is the World panels switch on the World row’s inspector, and setWorldPanels(on) sets it: off, the pins stay and nothing draws them. RowPanel stands the overlay’s pane over a row’s entity from beside the canvas, at a width in metres, which the world panels draw through. The debugger page has the rules, and Devtools panel says what a component declares for the panel.
Abilities
Section titled “Abilities”An entity that holds AbilitiesTrait shows an Abilities section in the overlay’s Inspector, after its behaviours. It shows the numbers that decide a cast, live, and draws the selected ability’s range on the ground.
The section holds the following:
- Target. The caster’s
TargetTrait, its reach in metres, whether it is in range of the selected ability, and whether it is in sight. With no target, the section says so. - Last refusal. The line a page shows for the last
CastRefusedEvent, such as “Out of range”, or “None yet”. The store keeps it after every system has read the event. - One button per ability. Each shows its kind, its range in metres (its radius for an area ability), its cost, the seconds its cooldown has left, and its cast state:
ready,pendingwhile the caster walks into range, orcastingwith the seconds of wind-up left. The cooldown is the longer of the ability’s own and the global one, since both hold a cast back. A press selects the ability. - Range rings. A switch that draws, round the caster, a ring at the selected ability’s range and a fainter one at
approachShareof it, where she stops when she walks into range. A line runs from her to her target, green while the target is in range and red while it is out. The inner ring appears only for an ability that approaches: an area ability gets its radius ring and no line, and a projectile or chain registered withapproach: falsegets its range ring alone.
The rings are the engine’s GroundMark.Ring and a line, drawn in the game’s canvas by AbilityRings from @spawnite/engine/devtools, and left out of a view capture as the other gizmos are. They last while the Inspector shows the row and the switch is on.
The store reads the same numbers for an agent:
readAbilityInspection(world, caster)returns{ target, abilities, refused }for a caster, orundefinedwhere it holds no abilities.useRowAbilities(id)is the hook the section reads: it renders again on each frame a number changes, since the reach and the cooldowns move with no write a subscriber hears.- While the overlay is mounted, every caster files the same record under
useInspect("abilities"), sospawnite play eval viewsand a dump read it without a selection. The last refusal is kept only while a world is attached to the devtools, whichwatchCastRefusalsdoes.
Unreal’s showdebug abilitysystem prints an actor’s abilities, cooldowns and effects on the screen, and its debug draw puts a sphere at a radius. Unity and Godot leave a range to an editor gizmo, OnDrawGizmosSelected and _draw, and read values in the inspector while the game runs. This section matches Unreal’s page and the gizmo ring, and differs in that it is part of the Inspector that exists, and its numbers reach an agent through useInspect, since an agent debugs a skill as often as a person does.
Commands
Section titled “Commands”The Commands section, at the rail’s foot, lists the attached world’s last 64 commands, oldest first: each by its handle’s name and request id, with its result and a refusal’s reason, then the tick her page sent it on, the ticks the room admitted and settled it on, and who settled it, her page before it left, the room’s engine, or the system that answers it by name. A predicted command the room refused after her page ran it reads undone. useCommandTimeline() is the hook it reads, which renders again on each change; a world keeps the timeline only in development. Unity’s Netcode and Unreal show no list of RPCs and their outcomes, since neither gives an RPC a result; Roblox’s Developer Console counts remote events without their answers. Cost per frame: none; one render of the section for each command’s change while it is open.
The chat
Section titled “The chat”The overlay mounts no chat, and the store keeps its state for the chat components. chatAttachments holds the ids of the rows put on the next chat message; attachToChat(id) adds one once, detachFromChat(id) removes it, and clearChatAttachments() empties it when the message goes. sentMessages keeps every message the page sent, oldest first: the line the creator typed, or the entity’s name where they typed none, how it went, sent to the dev server, copied to the clipboard with no dev server, or neither, the file the dev server wrote, and the rows it carried. recordSent(message) appends one. The store holds no message body: the dev server keeps those, and an agent reads them with read_chat or the hooks, as the dev server page says. Cost per frame: none.
The console
Section titled “The console”log holds the last two hundred lines the page’s console received, and clearLog() empties it. captureLog() wraps console.log, info, warn and error, and listens for uncaught errors and unhandled rejections on the window; the return restores the console. Each line carries its level, the simulated seconds it arrived at, the message as the console prints it, and its source: the file name of the first stack frame outside the wrapper. The overlay installs the capture while it is mounted, so a published bundle wraps nothing, and a test installs it itself. The engine’s own warnings go through console.warn and arrive the same way, so gameplay, which imports no store, has nothing new to import. A warning meant for the agent building the game, such as a walker’s CharacterCapsuleTrait cut to fit, prints only on a dev world, one marked with DevWorldTrait. Four hosts mark their world a dev world:
- The page in development mode, which the engine’s Vite plugin reports.
- The headless harness.
- A room that allows edits.
- A game’s own tests under vitest in jsdom or node, on the engine’s source or its published package.
A built game a player opens marks none, so it prints nothing.
Unity’s Application.logMessageReceived and Roblox’s LogService.MessageOut read the native log rather than keep a second one, and so does this. The MCP’s read_console on its headless page stays on the browser protocol, and spawnite play console reads its session’s page from Playwright’s own list of console messages and page errors, keeping the errors and warnings, which also catches an error raised before the store exists; on the creator’s own tab it reads the lines the page logged since the agent attached, with no file for each, since no protocol reaches that browser. Cost per frame: none; one stack read per console call.
The agent on this tab
Section titled “The agent on this tab”While an agent’s playtest is attached to this tab, as the dev server page describes, the Spawnite toggle, top left, lights green: its pane takes a green edge and glows in that green, as the gem that dots the i of its wordmark, or that stands alone on a phone, does, with no control of its own, and the two glows breathe together while a call runs. Its tooltip says “Agent connected” between calls, and the call the agent runs, such as “Agent: walk to (9.0, 0.0, 9.0)”, while it runs. The console keeps a line for each call that acts, a step, a walk, a click or a shot, and for the attach and the let-go; a read, such as the tree or the dump, keeps none. The agent’s calls fire spawnite:agent on the window with an AgentActivity, { state, action } from @spawnite/schema, and useAgentActivity() follows it. The agent fires its state again every agentHeartbeatMilliseconds between calls, and the toggle goes back to grey after three missed beats, so an agent whose process ended without stop_playtest is not shown attached until the page reloads. Roblox Studio shows a plugin’s work in its output window, and Unity marks play mode by tinting the editor; the green toggle does both in the corner a creator already watches for the frame rate. Cost per frame: none.
An entity’s stats have one card in the inspector, the Stats card, whatever declared them: the Stats component, Health’s maxHealth, or <CharacterSpawn>. The card has a slider on each stat’s base, and under the sliders the value each stat resolves to where its modifiers or bounds move it, with each modifier’s source and a timed one’s seconds left. The Stats component’s own row carries stats: true, and the card takes its place; an entity with no such row shows the card after its behaviours’ cards.
setStatBase({ id, name, base }) sets the base of a stat of the entity the row id stands for, as the engine’s setStatBase does, so every modifier still applies over it, and records it under the row’s statEdits until the component that declared the stat declares it anew: Health on a changed maximum, and the Stats component on any changed prop, since it declares all its stats at once. An edited base reads “Tried, not saved” on the card, and Copy as prop copies each edited stat the Stats component declared as its prop, side={{ base: 4, min: 0 }}; a stat another component or a system declared offers no copy. It throws on a stat the entity has not got and on a base that is not finite. A slider’s ends are read off the base as the selection found it: from zero to the stat’s max, or to the base where the base is past it, or else to the power of ten above the base, so 3.5 slides to 10 and 100 to 1000. The number box beside a slider takes any finite base, past the slider’s ends, as Godot’s or_greater range hint lets a typed value pass its slider; a behaviour’s own number box still refuses a value outside its declared range. On a room’s replica an entity the room streams keeps the room’s stats: the card is greyed with “The room keeps its stats”, and setStatBase throws it, because the room’s edit sets one field of a trait and a base sits inside the stats record.
A field that a stat sets every step has no slider on its behaviour’s card, since the next step would overwrite an edit: Health’s maximum where the entity has maxHealth, and on sled’s rider the track mover’s sideSpeed and drag. registerStatField(stat, trait, field) declares one, and the step then sets it, and readStatFields(id) returns a behaviour row’s fields that its entity’s stats set, each by its stat. The engine declares Health’s; a game declares its own. setProp throws on such a field, naming the stat that sets it and setStatBase as the way to change its base.
useRowStats(id) returns the stats of the entity the row id stands for and why they take no edit, and renders its caller again on each write of them, as the step writes them every step while a timed modifier counts down. An agent reads the same stats from dump(), under stats. Cost per frame: none while no stat changes; one render of the inspector for each write of the selected entity’s stats.
Unity’s inspector edits a stat’s serialized base, and its layout groups grey out the fields they drive, “Some values driven by” the group. Roblox’s Attributes panel edits the value itself, which has no modifiers to keep. Unreal’s GAS debugger shows attributes read-only. The Stats card edits the base as Unity does, so a buff or an upgrade stays on top of the edit, and hides the driven field where Unity greys it, so each number has one slider.
Systems and relations
Section titled “Systems and relations”describe(id) lists systems: the blocks of the step and the frame that touch the entity, read off its traits and never by instrumenting a step. input for a player’s character, motion for a transform with a velocity, rules naming the registered behaviours it has, physics for a collider or a walker, which is a transform with a velocity and no collider, then the frame’s render for a drawn object and camera for the character the camera follows. Unity’s Entities inspector lists the systems whose query matches the entity; this is the same list from a table rather than a query log. A game’s own systems are plain functions that declare no traits, so they are left out until System carries the traits it reads.
relations holds the parent and the children from the tree, follows and followed by from the camera’s follow, and chases from the nearest player’s character at the moment of the read. Koota’s relations carry a behaviour that targets one entity, once one exists. Cost per frame: none; both are computed when read.