Skip to content

Devtools API

The engine exposes everything the debugger draws, and everything an agent that cannot look needs, on one store: useDevtools. Nothing here draws. @daniel-zarinski/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 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.

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, Player and Camera have rows of their own. There are no invented groups.

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.

selectedId is the selected node and select(id) sets it. 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 mounted camera, and returns the row of the entity it meets. pickRowHit(x, y) returns that row with the world point where the ray meets it. The overlay passes the row to hover as the pointer moves over the game and to select on a click. On a right-click, it opens the context menu with the point beneath the entity’s name.

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 and the selected entity: 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 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. 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.

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. A modal’s pause is separate: it belongs to the modal stack and holds everything, where the devtools’ pause holds only the steps and the day clock that moves with them.

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. walkTo({ destination, steps }) sends the hero walking along the navmesh and steps until the hero reaches the route’s end, and returns arrived, the steps taken and where the hero 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.

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.

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 hero’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 other useFrame subscribers, 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. 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.

registerPanel({ title, component }) adds a game’s own inspector as one tab of the overlay, and the return removes it. panels lists them.

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.

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 stays on the browser protocol, which also catches an error raised before the store exists. Cost per frame: none; one stack read per console call.

The overlay’s Edit toggle sets editing, and the overlay turns it off when it unmounts. While editing is on, the loop records every frame as a dump of the world, keeping the last ten seconds. recordedFrames counts them, and seek(frame) reads one, where 0 is the oldest kept. setEditing(false) drops the recording. Seeking reads a frame; it does not move the world. Rewind, resuming from a scrubbed frame, is a later issue.

setFreeCamera(true) swaps the scene’s Camera, ChaseCamera or SideCamera for FreeCamera, and the hero stands still while the movement keys fly it, as Unreal’s eject does. setFreeCamera(false) gives the scene’s camera back, and so does leaving edit mode. A scene with no camera gets no free camera.

Everything below that changes the world, an edited prop, a hidden or deleted entity, an added behaviour, acts on the running world and is gone on reload, as an edit in Unity’s play mode is. The scene’s code stays the source of truth, and two paths turn an edit into code. Send to agent puts the edit in the bundle. Copy as prop, on a card whose row names the prop its component takes, copies the changed fields as that prop, movement={{ speed: 7 }}, for you to paste. An edited card reads “Tried, not saved” until then. Unity’s play mode discards its edits on exit, and Unreal keeps them only through Keep Simulation Changes; the copy is the same step for a component written in code. Duplicate takes that path too: it opens Send to agent with the intent “Add another beside this one”, because a copy made in the world would have no component to draw it and no row.

setProp({ id, name, value }) writes one prop of a behaviour row onto its trait through set, so every subscriber hears it. A row that adds two traits, as Chase does, writes the one whose schema has the field. The row records the write under edits, so the inspector marks the behaviour changed and shows the edited value, and resetProps(id) drops the edits and puts the component’s own props back on the trait. A prop that holds a function cannot be set, and a prop is set only to a value of its own type, so a typed-over array or number never reaches a trait as text. A number is clamped to the row’s declared range.

On a room’s replica, an entity the room streams is the room’s. Where the room’s welcome says it takes edits, setProp also sends the room an edit message: the entity’s dump key, the trait’s dump name, the prop and the value. The room writes it, clamped to the range the trait’s behaviour declares, and the write streams back to every client. Where the room refuses, every row of a streamed entity over a trait with a dump name is read-only, greyed with “The room keeps its settings”. A row over only traits with no dump name stays editable: the room never streams them, so nothing overwrites the edit. A room takes edits only when started with allowEdits or ROOM_EDITS=1, a development room: Roblox lets a client write its own WalkSpeed, the classic speed hack, and Unreal keeps movement on the server.

When the component renders again with the same props, the edit stays: the behaviour hook sets the trait only when the props’ fields differ from the ones it last wrote. A changed prop wins and the edit goes, which is React DevTools’ rule for an overridden prop. Headless, no component has mounted, so there are no rows to edit; a test sets the trait itself. Cost per frame: none; a shallow compare per render of the behaviour.

A preset is a named set of the props one entity’s behaviours hold, saved under the entity’s component name, so a preset saved on one Coin applies to every Coin. presets lists the names for the selected row’s component. savePreset(name) takes the selected entity’s props as they stand, and applyPreset(name) writes them through setProp. Each behaviour’s props are kept under its name, as a list in the entity’s order where it has two of one kind, so each applies to its own row; a read-only row is left as it is. preset names the last applied on the selected row. Presets persist in the browser’s storage under the game’s name, as the save does, and a test builds the store over a map. Unity’s Preset assets are the shape; the other engines have none. Cost per frame: none.

The overlay’s menu offers actions the store carries out. readDisabledActions(id) says why an action is off for a row, by action, and the menu shows the reason; an action called while off throws it. The actions are the following:

  • focusCamera(id) flies the mounted orbit to the entity, at the distance that fits its model’s bounds, with a transition, and sets the camera’s follow to null so the frame’s aim stops pulling the eye back to the player. Leaving edit mode puts the follow back. Off with “No camera is mounted” or “Nothing draws it”. In Unity, Unreal, Godot and Roblox Studio, F moves an editor camera; the game has one camera, so it stops following instead.
  • hide(id) and show(id) flip the entity’s object’s visible, and hiddenIds lists the hidden rows. The frame loop applies it again after the transforms sync, so a model that loads later stays hidden, and pickEntity skips a hidden object. Physics and behaviours run on, as under Unity’s scene visibility and Unreal’s H key: the view is hidden, not the entity. Off with “Nothing draws it”.
  • deleteEntity(id) destroys the entity. Entity and Player see their entity go and take its children and its row down. Off with “The world” and “The camera’s rig”, whose components do not watch for that.
  • addBehaviour(id, name) adds a registered behaviour’s trait with its defaults and registers a row for it under the entity, which goes when the entity does. behaviourOptions(id) lists what the entity can take: each behaviour registered with registerBehaviour(name, behaviour) that it lacks, with the description it declares as its summary. The engine registers Spin, Bob, Pickup and Health. A behaviour whose component does more than add the trait, as Interact draws its prompt and listens for E, Trigger fires its callbacks and Chase adds a body, is not registered, because its trait alone would run half of it; it puts its trait in the dump with registerTrait. Off with “Has every registered behaviour”.
  • Duplicate goes through Send to agent, as above. Copy id, Inspect, Select parent and Send to agent are the overlay’s own.

Every action is a call on the store, so an agent’s what-if is the same call: delete a coin, add Spin, stepUntil, then screenshot. Headless, focus and hide report their reason. Cost per frame: none for focus and delete; one visibility write per hidden entity.

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 hero, move for a transform with a velocity, behaviours naming the registered behaviours it has, resolve 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 hero 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 hero 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.

In development, ?map=<name> in the URL shows that registered map alone: the overlay registers a map-preview scene holding a <World map={name}> and a camera the page drags, and goes to it. No player spawns, and every <Hud> draws nothing while mapPreviewScene is the active scene, so a readout the game mounts outside its scenes stays off the map. The game’s start scene mounts first, because the overlay loads after it, and then gives way. frameView("map") then frames the whole preview from above.

Three calls serve an agent that cannot look:

  • step(seconds) advances the bound world by that many seconds of fixed steps on its last input, with no clock. It is the harness’s stepSeconds, and an MCP call: it is never a control in the overlay.
  • dump() returns the world as JSON, the harness’s dumpState: every entity’s data traits keyed by id, and a hash of them.
  • capture(view) returns a PNG of the scene from a bookmarked camera, and frameView(name) gives the camera for a name on the mounted map: a views entry, a region, a path, or map for the whole ground from above. The capture leaves out a gizmo, an object drawn for the play camera alone, such as the destination marker; a view marks one by setting gizmoUserData as its root object’s userData. The playtest’s screenshot takes the name as view, and a size for the viewport, so an agent shoots the same zone after every edit; the world page says what a view holds.
  • frameShot(camera, aspect) gives the camera for a shot with no view written first, from the mounted map’s data: an angle, a subject and a zoom, or a raw eye and target. With no map mounted, as in a track game, all frames everything the scene draws. The camera page says what each takes. The playtest’s screenshot takes it as camera, and a map to shoot through the ?map= preview, and names the eye and the target it resolved to.
  • describe(id) returns one entity’s context bundle: its row in the tree, its behaviours with their props, the wiki page each behaviour declares, the source file a game’s own behaviour declares, the systems that touch it and its relations, where it stands on screen, and a PNG screenshot framed on the entity from the running renderer. Headless, there is no screenshot and no screen rect.

The dump reads the engine’s traits and each trait a game registers. A Koota trait carries no name, so a game names its own with one call beside it, and the dump and describe show it under that name:

export const ScoreTrait = trait({ points: 0 });
registerTrait("score", ScoreTrait);

Leave a trait that holds a handle unregistered: a physics world, a bake, or an object in a scene.

A behaviour declares its description, its page and its file beside its trait, and its row carries all three, so the inspector’s card shows the description. A behaviour whose component only adds the trait registers under a name, which puts the trait in the dump and the behaviour in the context menu’s Add behaviour list:

export const GlowBehaviour: Behaviour<typeof GlowTrait> = {
trait: GlowTrait,
runsOn: RunContext.Client,
description: "Glows brighter as the hero nears.",
wiki: "/behaviours/glow/",
source: "src/behaviours/glow.ts",
};
registerBehaviour("glow", GlowBehaviour);

In Node, the store comes from @daniel-zarinski/engine/devtools and the harness from @daniel-zarinski/engine/core, so nothing draws and no asset loads. A game with its own systems passes them to createHeadlessGame as systems, the same list it gives Game:

import { createHeadlessGame } from "@daniel-zarinski/engine/core";
import { attachDevtools, useDevtools } from "@daniel-zarinski/engine/devtools";
const game = await createHeadlessGame({ scene: spawnCoins });
const detach = attachDevtools(game);
const { step, dump } = useDevtools.getState();
step(5);
const { entities, hash } = dump();
detach();

A scene’s own component mounts in Node too, through the engine’s headless mount, on a root that draws nothing: the views that load a model, a sound or a VRM, the camera, and Hud, Panel and Modal render nothing headless, and the traits the rest fill are what the steps run on. A game’s own component that loads a file or reaches the page reads useHeadless() from the engine and renders nothing when it is true, as the engine’s do. useSaveStore() and useQualityStore() work headless too: each mount builds its own stores, which start empty and keep nothing once it ends. The components render between the calls that step the world, not between the steps, so a modal that opens on a step, a round screen for one, holds none of them up: read the round’s state off the dump.

import { mountHeadlessScene } from "@daniel-zarinski/engine";
import { attachDevtools, useDevtools } from "@daniel-zarinski/engine/devtools";
import { Run } from "./scenes/Run";
const game = await mountHeadlessScene(Run);
const detach = attachDevtools(game);
useDevtools.getState().step(5);
const { entities, hash } = useDevtools.getState().dump();
detach();
await game.unmount();
game.world.destroy();

The cli’s game simulate --scene <name> and the MCP’s simulate tool mount the component src/scenes/<Name>.tsx exports under that name this way, loading it and the engine through the game’s own Vite, step the world seconds of game time or until an expression holds with keys held, and print this dump with its hash.