MCP endpoint — AI-agent editor control
The Orkige editor hosts an MCP server itself, over Model Context Protocol Streamable HTTP (the stable remote transport, spec 2025-03-26): a single POST /mcp endpoint speaking JSON-RPC 2.0. An AI agent (Claude Code, Claude Desktop, …) connects to the running editor as a remote MCP server and drives it: open projects, edit and save scenes, inspect and mutate the GameObject hierarchy, read and write component properties, control play mode, take screenshots and list assets.
This document is the reference (endpoint, auth, transport, the tool table and per-tool semantics). For worked agent walkthroughs — authoring a feature, the edit→test loop, debugging a live game — see the tutorial companion Docs/mcp-workflows.md.
There is no Python sidecar and no extra pip dependency — the editor hosts the endpoint in-process. The HTTP server is hand-rolled on the engine's own non-blocking socket layer (core_debugnet/HttpServer), with a minimal nested-JSON value type (core_debugnet/Json) for the JSON-RPC surface.
Launch the editor with the MCP endpoint
The endpoint is default-on for an interactive session: an editor a person launched starts it automatically on an ephemeral loopback port, writes its auth token to the writable app-support dir (mcp-endpoint.token, owner-only), and — while a project is open — writes the per-project discovery file <projectRoot>/.mcp.json (see below), so an agent finds it with no manual setup. Automated runs and the ctest suite never open the socket (they opt in explicitly). Opt out with ORKIGE_MCP_PORT=0 (also off/none/no).
Pin an explicit port and token file for a scripted launch or to share a stable URL:
# explicit port + published token (the client reads both from the token file)
orkige_editor --mcp-port 9010 --mcp-token-file /tmp/orkige.token
# env equivalents (both the new ORKIGE_MCP_* and the historical
# ORKIGE_CONTROL_* names are honored)
ORKIGE_MCP_PORT=9010 ORKIGE_MCP_TOKEN_FILE=/tmp/orkige.token orkige_editor
--control-port / --control-token-file remain accepted aliases. With a token file the editor writes <port>\n<token>\n to it (so a launcher that passed port 0 can discover the ephemeral port), and the token is required on mutations.
Project auto-discovery (.mcp.json)
While the endpoint is live and a project is open, the editor writes the project-scope MCP config <projectRoot>/.mcp.json — the file a claude session started in the project directory (the embedded terminal starts there) auto-loads. Its shape is exactly what claude mcp add --transport http -s project produces:
{ "mcpServers": { "orkige": {
"type": "http",
"url": "http://127.0.0.1:<port>/mcp",
"headers": { "Authorization": "Bearer <token>" },
"x-orkige-managed": true } } }
The x-orkige-managed marker (a field claude ignores) lets the editor manage only the entry it wrote: a user-authored orkige server without the marker is left untouched, every other server the user authored is preserved across rewrites, and an unparseable file is never clobbered. The file is rewritten on a project switch / port change and removed on clean shutdown (a crash leaves it; the next launch's ephemeral rewrite self-heals it). It quotes the bearer token, so it is written owner-only — and this one sits at the project root, which can be any volume the user picked, so the restriction travels with the write instead of being inherited from a directory. It is gitignored, hidden from project file listings, and never bundled by the exporter. claude prompts once per session to approve a project's .mcp.json; because the default port is ephemeral, that approval recurs each launch (pin --mcp-port for a stable URL that is approved once).
Register with Claude
Point the MCP client at the editor's URL (no command to spawn — it's a remote server):
# read the published token, then register the HTTP endpoint
TOKEN=$(sed -n 2p /tmp/orkige.token)
claude mcp add --transport http orkige http://127.0.0.1:9010/mcp \
--header "Authorization: Bearer ${TOKEN}"
(If your claude CLI version uses a different flag for headers, pass the Authorization: Bearer <token> header however it accepts custom headers, or run the editor without a token file — --mcp-port 9010 alone — to disable auth for a local dev session; with no token the port is fully open, and with a token EVERY request needs it — reads included, see the security posture below.)
Claude Desktop: add an entry under mcpServers with "type": "http" and "url": "http://127.0.0.1:9010/mcp" plus the Authorization header.
From the editor's embedded terminal
When the MCP endpoint is live, the editor's Terminal panel is the fastest path: a shell spawned there already carries ORKIGE_MCP_URL and (when set) ORKIGE_MCP_TOKEN_FILE in its environment, and the panel shows a copyable claude mcp add ... line wired to those variables. Start an agent inside the panel, paste, and it is registered against the running editor it lives in. The terminal itself is not an MCP verb (a headless agent spawning UI shells is out of scope). See Docs/terminal.md.
Security posture
The MCP endpoint grants a remote client full editor control — scene authoring, project-file writes, arbitrary Lua/editor-script execution, and Play. The player's debug link (core_debugnet/DebugServer, the play-mode editor↔player protocol) is a second socket surface. Threat model: in an AI-agent development setting neither surface may be reachable by any process other than the intended local client, and the token that gates access must not leak through a timing side channel. Three properties enforce that; security.md is the engine-wide umbrella (endpoints, tokens, jails) this section details.
-
Loopback-only by default. Both the MCP
HttpServerand the playerDebugServerbind127.0.0.1only — they are not reachable off the machine. Binding a non-loopback interface is an explicit opt-in:--mcp-bind 0.0.0.0/ORKIGE_MCP_BIND=0.0.0.0(aliases--control-bind/ORKIGE_CONTROL_BIND) for the editor endpoint and--debug-bind 0.0.0.0for the player debug port (any/all/*are accepted spellings; anything unrecognized stays loopback so a typo cannot silently expose the surface). It exposes the control/debug surface to the network — only do it behind a trusted boundary (a private lab network, an SSH tunnel's far end), and the editor logs a warning when it does. The regression is pinned byServerBindTests(both servers assert loopback by default via theisLoopbackOnly()seam plus a live loopback connect, and the opt-in flips it). -
Token required on reads, not just mutations. When a token file is
configured, every verb needs the
Authorization: Bearer <token>header — reads (get_state,list_hierarchy,read_project_file, …) included, so an unauthenticated peer cannot exfiltrate the project structure or source over the network. Only the handshake/liveness verbs (hello, which itself carries and checks the token, andping) are reachable pre-auth; the MCPinitialize/tools/listdiscovery stays open (it exposes only the static tool schema). In no-token dev mode the port is fully open — with no token file it is open for a hand-started local session. The policy is the purecore_debugnet/ControlAuth::verbAllowed(unit-tested byControlAuthTests), and theeditor_controlself-test drives a read WITHOUT the token and asserts it is refused while a token is configured. -
The token is unpredictable, and the files that carry it are owner-only.
It is minted per session from the platform's entropy source (
core_util/SecretToken.h) — never from a seeded engine whose other outputs are published, which would make the secret recoverable from files anyone can read. Both the token file and the per-project.mcp.jsonare written through the owner-only file sink (core_filesystem/FileWriter::beginOwnerOnly): they are created empty, restricted while still empty, written, then renamed, so the token never sits in a file another account can open. The restriction is0600on macOS/Linux and a protected DACL on Windows; a volume that can hold neither gets one honest warning rather than a refusal. What it does not stop is code already running as this user — the bound on that is the token's session lifetime and its removal on shutdown, not the file mode (security.md). -
Constant-time token comparison. The bearer token is compared with
core_util/constantTimeEquals(unit-tested byConstantTimeCompareTests), which folds every byte into one accumulator with no early exit, so the reply latency never reveals how many leading bytes of a guess matched — closing the byte-at-a-time timing oracle a plain==/strcmpleaks. The publicBearerscheme prefix is still matched normally (it is not secret).
Architecture
Claude ──HTTP POST /mcp (JSON-RPC 2.0)──▶ editor MCP endpoint
(EditorControlServer → HttpServer)
EditorControlServer is an HTTP + JSON-RPC transport in front of the existing verb handler, which is REUSED wholesale: a thin adapter over EditorCore (deliberately UI-independent) and the EditorDocument free functions. Each editor verb is surfaced as an MCP tool with a JSON inputSchema; a tools/call converts the tool arguments into the handler's internal request and the reply back into MCP tool content (a text block + structuredContent, or isError for a refused/failed verb). The image-capturing verbs (screenshot, preview_ui, preview_game, preview_animation) additionally inline the just-written PNG as an MCP image content block ({"type":"image","data":"<base64>","mimeType":"image/png"}) so a remote client sees the render directly — the path stays in structuredContent for pixel diffing. A PNG over 4 MiB is skipped (with "inline_skipped":"too_large" in structuredContent), and inline:false opts out; either way the path is returned. screenshot_game is async (the player writes the file after the accepted reply), so it stays path-only — read the confirmed path off the shared filesystem.
-
JSON-RPC surface:
initialize(→ protocol version +toolscapability +server info),
notifications/initialized(→ 202 Accepted, no body),ping,tools/list(→ the tools with schemas),tools/call.idis JSON-RPC's native correlation. -
Transport: POST-only. The optional Streamable-HTTP GET-SSE stream is not
implemented — the tool surface is request/response; long ops (play boot) return an accepted result and are polled via
get_state. -
Auth: with a token file configured, EVERY verb needs the
Authorization: Bearer <token>header (the token from the token file) — reads included (see "Security posture" above). No token file ⇒ auth off (dev convenience). The bearer is compared in constant time. -
Path jail: the file-authoring verbs (
write_project_file,read_project_file,list_project_files, andimport_asset'stargetDir) are confined to the open project's root. The requested path is normalized and canonicalized and its containment verified before any I/O — an absolute path, a..traversal (even one that only escapes after normalization) and a symlink component that resolves out of root are all refused with an honest error, nothing written. Legitimate nested paths (assets/textures/foo.png) work. The shared containment primitive iscore_util/PathJail; the same guard hardens pak/zip mounting against zip-slip (see filesystem.md). -
Play control (
play/stop/pause/resume/step), the run tools(
list_play_targets,play'sscene/target, thebuild_*fields ofget_state) and the runtime debug tools (runtime_hierarchy/runtime_select/runtime_state/set_runtime_property/set_cvar/reload_script/screenshot_game— see "Debugging a running game") are translated into the ONE existing player debug protocol — the MCP endpoint is editor-side, never a second player port.
Tools
The toolSpecs table in EditorControlServer.cpp is the advertised tool list — the source of truth, mirrored below. Each tool maps onto an existing EditorCore method or an EditorDocument free function; nothing bypasses the verb handler. (tools/call will run any verb the handler knows, so the advertised set is the discoverable subset, not a hard limit.)
| Tool | Maps to |
|---|---|
get_state | project/scene/dirty/selection/play-mode snapshot (+ the prefab-edit stage: edit_context (scene/prefab), and while a prefab is staged prefab_path/prefab_root/prefab_dirty plus scene_path/scene_dirty re-reported from the STASHED scene the close restores; + build_status/build_target/build_errors for compile-on-Play; while a play session runs, the streamed-music snapshot: parallel music_ids/music_files arrays plus a music_info string per track — "playing positionSec durationSec baseGain groupVolume effectiveGain loop", streamed on MSG_STATS, empty when nothing plays; plus the runtime perf summary: frame_ms, the engine-level allocation counters alloc_per_frame/alloc_peak with the per-subsystem breakdown in parallel alloc_tags/alloc_counts, and profile_seq — see get_profile; plus transaction_open — 1 while a begin_transaction atomic-edit bracket is open) |
open_project(path) / new_project(path) / close_project(force) | openProjectFromPath / newProjectAtPath / closeProject (dirty-state policy) |
new_scene(force) / open_scene(scene, force) / save_scene(scene) | newScene / openSceneFromPath / saveSceneToPath |
list_hierarchy() / get_object(id) | GameObjectManager::getGameObjects (+ parent/active). The ids come in hierarchy order — depth-first from the scene roots, siblings in their live order (the Hierarchy panel's order and the .oscene document order), so reorder_object reads back here |
get_component(id, component) | a component's reflected properties (generic over the property registry) + the discovery lists kinds/hints/readonly/transient |
set_component(id, component, properties) | the undoable reflected setter (EditorCore::applyPropertyChange, validated then merged into one undo step) |
create_object(id, mesh, position) / delete_object(id) / duplicate_object(id) | CreateObjectCommand / DeleteObjectCommand / DuplicateObjectCommand |
rename_object(id, new_id) / reparent_object(id, parent, anchor?, after?) / set_active(id, value) | EditorCore::renameObject / reparentObject / setObjectActive. anchor is an optional sibling of the new parent the object lands next to (after 1/0) instead of at the end of the child list |
reorder_object(id, anchor, after?) | EditorCore::reorderObject — move an object among its OWN siblings, directly before (after 0, the default) or after (1) anchor. Sibling order is scene state: list_hierarchy reports it, the Hierarchy shows it, and save_scene writes it as the .oscene document order, so the arrangement round-trips. Refused when the two objects do not share a parent (use reparent_object with an anchor) or the order would not change |
set_persistent(id, value) | EditorCore::setObjectPersistent — the object survives the level system's mid-play scene switch with its whole live state (a persistent parent keeps its subtree); read it back from get_object's persistent field |
add_component(id, component) / remove_component(id, component) / list_addable_components() | addComponentToObject / removeComponentFromObject / getAddableComponentTypes |
select(id) / undo() / redo() | EditorCore::selectObject / undo / redo |
get_view_options() | the Scene view display options (the toolbar Display dropdown), each 1/0: show_grid, show_colliders, show_bounding_boxes, show_all_camera_frames, show_sky, show_view_gizmo, mode_2d; plus view_mode (shaded/wireframe/shaded_wireframe) and lighting (1 lit / 0 unlit) for the Scene RTT, with wireframe_supported/wireframe_overlay_supported/lighting_supported (1/0) telling which the current render backend can do. Read to know what the Scene view is drawing before a screenshot |
set_view_option(option, value) | auth — set one Scene view display option (persisted in orkige_editor_view.ini like the toolbar dropdown, applied next editor frame). option ∈ grid/colliders/bounding_boxes/camera_frames/sky/view_gizmo/mode_2d (value 1/0), view_mode (value shaded/wireframe/shaded_wireframe) or lighting (value 1 lit / 0 unlit). Colliders draw each RigidBodyComponent Jolt shape, bounding boxes the renderable world AABBs, camera frames every CameraComponent frustum plus the Preview device's design-aspect rect — editor-only facade line meshes, so pair with screenshot (editor) to verify an overlay visually. sky shows the scene atmosphere's sky (dome/cubemap) in the Scene view (default off — the Preview and Play always show it; lighting and fog are unaffected). view_mode renders the Scene view's 3D geometry (the Preview and Play keep the real game look); wireframe works on both backends (wireframe_supported is 1) — classic flips the Scene RTT camera's polygon mode (per-target), Ogre-Next bakes polygon mode into pipeline-state objects with no per-target override so it is a global flip of the 3D-scene datablock set only (the 2D/UI tier stays solid), armed only on a frame the Scene view owns (like lighting off) so it never leaks into the Preview/Play. shaded_wireframe is the solid shaded scene with a thin wireframe drawn on top — OVERLAY ITEMS (a second wireframe renderable per scene mesh, never a mid-frame polygon flip), working on both backends (wireframe_overlay_supported is 1); the overlays carry the editor-only visibility bit so they stay out of the Preview/Play, and animated/skinned meshes are excluded (static scene geometry is the mode's habitat). view_mode stays capability-gated (query wireframe_supported for the flip and wireframe_overlay_supported for the overlay first; an unsupported value is refused with its reason). lighting off is a global per-frame flat look (albedo + bright ambient, every light suppressed) that works on both backends (lighting_supported is 1) — a per-target route is impossible (HlmsPbs gathers directional lights from the global light list, unfiltered by the per-pass mask; classic has no per-view override), so the editor arms it only on a frame the Scene view owns (the Scene view visible, the Preview not). Over MCP set_view_option lighting 0 sets the toggle; whether the frame actually flattens still depends on that dock-tab visibility (the Preview and Play always stay lit). An unknown option is refused |
begin_transaction() / end_transaction(commit) | auth — one atomic-edit bracket for a remote client, over EditorCore::begin/endScriptTransaction (the SAME one-undo primitive .editor.lua tools use). Everything run between them folds into ONE undo step on commit=true, or unexecutes wholesale on commit=false. A double begin / an end with no begin is an honest error. get_state reports transaction_open. The bracket spans many HTTP requests, so it AUTO-ABORTS (rolls back, one Console line) if the editor switches scene/project/prefab, starts Play, or shuts down under it — keep it short-lived (a manual editor edit the owner makes in between is folded in too; the fold is origin-blind) |
play(scene?, target?, force?) / stop() / pause() / resume() / step() | startPlay / requestStopPlay / pause·resume·step over the player protocol; scene opens+plays a scene (jailed), target picks the device (applyPlayTarget) |
list_play_targets() | the Play target picker's enumeration (listSimulators/listIosHardwareDevices/listAdbDevices) → target_kinds/target_ids/target_names/target_states |
screenshot(path, window, inline?) | the EDITOR: RenderTexture::writeContentsToFile (chrome-free viewport) / RenderSystem::saveWindowContents (whole window) → returns the written path AND inlines the PNG as an image content block (inline:false or >4 MiB opts out) |
runtime_hierarchy() | the RUNNING game's live hierarchy (ids/parents/active), streamed from the player |
runtime_select(id) | choose which running object streams its component state (MSG_SELECT) |
runtime_state() | the streamed component state of the selected running object (values + kinds/hints/readonly) |
set_runtime_property(id, component, property, value) | write one reflected property on the RUNNING game live (MSG_SET_PROPERTY) |
set_cvar(name, value) | change a console variable on the RUNNING game live (MSG_SET_CVAR) |
reload_script(id?) | hot-reload Lua on the RUNNING game — one object or all (MSG_RELOAD_SCRIPT) |
reload_ui(file) | hot-reload one declarative .oui screen on the RUNNING game — destroy-and-rebuild its widgets from the fresh file (MSG_RELOAD_UI); a parse failure keeps the OLD screen and surfaces a [remote] error, a rebuild emits the ui.reloaded script event. The editor's .oui watcher fires this on a file save too |
reload_anim(file) | hot-reload one vector-animation rig (.oanim) on the RUNNING game (MSG_RELOAD_ANIM): the player parses the fresh file FIRST, then rebuilds every VectorAnimationComponent playing it (clean cutover — playback restarts at each component's reflected clip); a parse failure keeps every OLD rig and surfaces a [remote] error naming the line, a rebuild emits the animation.reloaded script event. The editor's animation watcher fires this on a file save too (re-cooking a changed Lottie source first); after a reimport_asset during Play, call it yourself |
reload_mesh(file) | hot-reload one parametric mesh (.omesh) on the RUNNING game (MSG_RELOAD_MESH): the player parses the fresh text FIRST, then retires the old mesh resource and rebuilds every ModelComponent naming it (a mesh resource cannot be rewritten under live instances, so the instances go first); a parse failure keeps the OLD geometry on screen and surfaces a [remote] error naming the line. Author the text with write_project_file — no cook step. The editor's .omesh watcher fires this on a file save too |
screenshot_game(path) | screenshot the RUNNING game's frame (MSG_SCREENSHOT, desktop play) → poll get_state (async: the file is written after the accepted reply, so this verb stays path-only — no inline image) |
record_trace(path, seconds?, everyNth?, objects?) | record a temporal TRACE of the RUNNING game to a .jsonl flight recorder (MSG_RECORD_START, desktop play) — per-frame object samples (pos/vel/active/visible + dt + mem process footprint) with contact/scene/error/warning events → poll get_state for record_seq |
stop_recording() | end an in-progress record_trace early (MSG_RECORD_STOP); the player writes what it captured → poll get_state |
set_breakpoint(file, line) | auth — set a script breakpoint (project-relative script path + 1-based line) in the editor's per-project store (ScriptBreakpointStore, persisted under .orkige/breakpoints); the play session pushes the whole set to a running player automatically (MSG_DEBUG_BREAKPOINTS, on connect and live on any change) — so set it before OR during Play. Refused for a browser play session and by scripting-disabled players (the honest one-line reply). Returns the full set. See Docs/script-debugging.md |
clear_breakpoint(file?, line?, all?) | auth — clear one breakpoint, or every one with all='1'; pushed live like set_breakpoint. Returns the remaining set |
list_breakpoints() | the per-project breakpoint set as file:line entries (read-only) |
get_debug_state() | THE poll for the asynchronous break-hit: paused_at_breakpoint (1 while the player is held MID-STATEMENT at a script break — distinct from the frame-boundary pause), the paused file/line, break_error (the crash text when the pause came from an uncaught error — see set_break_on_errors; empty for a breakpoint/step pause), break_seq (increments per break — a landing step raises a new one), locals_seq, and the call stack as parallel stack_sources/stack_lines/stack_functions (innermost first; host-call frames read [host]). Answers in every mode |
set_break_on_errors(on) | auth — BREAK ON SCRIPT ERRORS: arm (on='1') or disarm (on='0') pausing the running game AT an uncaught Lua error (MSG_DEBUG_BREAK_ON_ERRORS). Armed, a thrown error pauses the player at the error point like a breakpoint — poll get_debug_state until paused_at_breakpoint, then break_error/file/line/stack point at the fault; inspect with get_locals and release with debug_continue (on Continue the error still disables the instance — arming only DEFERS the honest failure). Persisted in the editor and pushed to a running player on connect + on change. Refused for a browser session and by scripting-disabled players; on those the error still flows its normal path |
debug_continue() | auth — release the held break and run free until the next hit (MSG_DEBUG_RESUME); returns accepted + prev_break_seq — poll get_debug_state |
debug_step_in() / debug_step_over() / debug_step_out() | auth — release the held break and pause again at the next executed line / the next line at the same-or-shallower call depth / the first line after the current function returns (MSG_DEBUG_STEP_*); async like debug_continue — poll get_debug_state until break_seq advances |
debug_break_next() | auth — BREAK ON NEXT STATEMENT, the orientation move: pause into wherever the running scripts execute next, WITHOUT a known file/line (answering "where does code even run right now?"). Arms a one-shot break on the first script line the player runs next (MSG_DEBUG_BREAK_NEXT); works while play_mode is playing or paused (a frame-paused arm persists until the sim resumes). Errors unless a live player is running and NOT already paused; refused for a browser session and by scripting-disabled players. Async like debug_continue — returns accepted + prev_break_seq, poll get_debug_state until break_seq advances, then inspect with get_locals |
get_locals(frame?, expand?) | variables at a frame of the held break (MSG_DEBUG_LOCALS): frame indexes get_debug_state's stack (0 = innermost); expand names a root variable plus a chain of table keys ([3] bracket form for non-string keys) to list ONE nested table — bounded, never a whole-state dump. ASYNC: a first call may answer pending='1' — call again (a frame or two of editor pumping). A ready reply carries parallel names/scopes (local/upvalue/field)/types/values/expandable |
list_assets() | AssetDatabase::listAssets + Project::listScenes |
write_project_file(path, content) | write a text file under the open project's root (jailed; LF endings; parent dirs created) |
read_project_file(path) | read a text file under the project root (jailed; 1 MiB cap) |
list_project_files(dir?, glob?) | list one directory level under the project root (jailed) → names/paths/types |
preview_ui(file, language?, width?, height?, scale?, insets?, contexts?, path?, inline?) | auth — render a project .oui screen at a SIMULATED device context into an offscreen target and return a screenshot + resolved widget rects, no running player needed (the centrepiece of the collaborative UI loop). Renders through the REAL gui stack, isolated from any running game. Single context from width/height (pixels) + scale (1/2/3) + insets ("l t r b"); OR contexts for a device-matrix sweep (;-separated WxH[@scale][/l,t,r,b]). Optional language resolves the screen's @key captions in that target language (from the project's loc/ directory); omit for the source language — preview a screen in German without a play session. Returns path (single) or paths+context_labels (sweep), width/height, batch_count, parallel ids/rects (each rect "left top width height visible enabled modal", first context for a sweep), the applied language and the available languages (a project with no loc/ directory ignores language with a language_note). The screenshot is also inlined as an image content block (the FIRST context for a sweep, to bound payload; inline:false or >4 MiB opts out). Ogre-Next only (classic reports an honest error); does not disturb the human's GUI Preview tab |
preview_game(preset?, width?, height?, scale?, camera?, screen?, language?, animate?, animate_time?, path?, inline?) | auth — render the AUTHORED SCENE through its own scene camera at a SIMULATED device preset into an offscreen target and return a screenshot — "what will this look like on the device?" without playing (no scripts, no gameplay ticking; the editor safety contract). Renders the CURRENT editor scene through the CameraComponent's reflected projection + its object's world transform (a "Main Camera" if present, else the first camera; override with camera = an object id). preset is a real-device token (free / iphone_se / iphone_notch / iphone_notch_landscape / iphone_island / android_compact / android_large / ipad / ipad_pro_11 / ipad_pro_129 / ipad_landscape / fold_cover / fold_main / galaxy_flip / custom) OR explicit width/height (pixels) + optional scale. Optional screen composites a project .oui OVER the scene (Ogre-Next only, needs an open project); optional language resolves its @key captions. Optional animate:true + animate_time (seconds, default 0.5) advance MATERIAL-parameter animation only (e.g. water scroll) before the shot — the world still never runs gameplay. Returns path, the resolved width/height, the tracked camera and the composited overlay. A camera-less scene still renders — through the DEFAULT window camera the player boots with (the preview mirrors the game), reported as default_camera:true with an empty camera. The screenshot is inlined as an image content block (inline:false or >4 MiB opts out); the device-preset table is the pure core_util/DevicePreset. Does not disturb the human's Preview tab. Both flavors (the scene render; the .oui overlay is Ogre-Next only) |
preview_animation(asset, clip?, time?, blendClip?, blendWeight?, size?, path?, inline?) | auth — render a vector-animation rig (.oanim) at a chosen clip/time into a PNG + return the pose readback, no running player needed (the animation twin of preview_ui). The rig is evaluated on the editor's own clock (core_util/VectorAnimEval) and CPU-rasterized (the same raster that draws .oanim thumbnails), so an agent can scrub a cycle (t=0 / mid / end) and try a same-rig blend (blendClip + blendWeight 0..1, mixed at the same time) without a play session. Returns path (the PNG), the resolved clip/frame/time, the rig duration (frames)/fps, layer_count/shape_count/vertex_count, visible_pixel_count/coloured_pixel_count, at_end, and the available clips. The pixel counts make blank or all-white regressions machine-detectable. The PNG is also inlined as an image content block (inline:false or >4 MiB opts out). Both flavors (pure CPU raster — no offscreen target); does not disturb the human's Inspector animation preview |
import_asset(sourcePath, targetDir?, clips?, extent?, tolerance?) | copy an OUTSIDE file into the project via importAssetFile (sidecar minted, id returned; optional relocate via AssetDatabase::moveAsset). An .svg source is cooked to a native .oshape on the way in (in process, no interpreter); a Lottie .json is cooked to a native .oanim (in process too, core_util/VectorAnimCook) with the SOURCE .json KEPT beside it (both id-tracked, re-cooked on re-import) — the returned path/assetId point at the cooked .oanim (or .oshape for a document where nothing animates), and a .oanim reply also carries the rig's clips (names in rig order — clip discovery lands with the import). The optional cook params (clips = name:start:end[:loop|once],... marker override, extent, tolerance) are applied to the Lottie cook AND recorded on the kept source's sidecar as the per-asset intent (Docs/vector-animation.md#import-records-and-automatic-re-cooks) |
reimport_asset(asset, clips?, extent?, tolerance?) | auth — re-cook an imported animation pair IN PLACE via recookAnimationPair (the MCP face of the Asset browser's "Reimport"): run the Lottie cook on the project's kept .json source — the re-cook trigger a source edited via write_project_file needs, since a plain file write never cooks. asset is the project-relative .json (or its cooked .oanim/.oshape — the pair resolves either way); optional cook params update the sidecar's recorded settings first, omitted ones keep the recorded values. Returns the produced path, the source, the recorded settings (clips_setting/extent_setting/tolerance_setting), the recorded input hashes (source_hash/tool_hash/settings_hash) and a fresh rig's clips. During Play, follow with reload_anim |
create_prefab(objectId, path) | PrefabSerializer::savePrefab + AssetDatabase::importAsset + EditorCore::makePrefabInstance (write a subtree as a .oprefab, convert to an instance) |
instantiate_prefab(path, parent?) | CreatePrefabInstanceCommand (a fresh instance of a .oprefab, optionally reparented) |
open_prefab(path|asset) | auth — openPrefabForEdit (swap the live scene aside into a temp snapshot and load the .oprefab subtree into the ONE edit world). Give the prefab by project-relative/absolute path OR stable asset id. Returns the stage root_id (the file stem) + prefab_path; get_state reports edit_context=prefab. While staged, EVERY editing verb (hierarchy CRUD, get/set_component, undo/redo, screenshot, run_editor_script) operates on the prefab subtree UNCHANGED, and the scene/project lifecycle, play, add_scene_to_levels and the paint/instance verbs are refused with the prefab-mode error |
save_prefab() | auth — savePrefabEdit (write the open stage back to its .oprefab; the open scene's instances refresh from the rewritten file with their per-instance overrides re-applied at close). Refused (honest error) when the stage root was deleted or objects exist OUTSIDE the single root. Returns prefab_path |
close_prefab(policy) | auth — closePrefabEdit (restore the snapshotted scene and pop the stage). policy is REQUIRED — save (write the prefab first; a refused save cancels the close) or discard (drop unsaved stage edits). Returns the restored scene_path; get_state reports edit_context=scene again |
list_paintable_assets() | searchAssets (the project's paintable palette: prefabs + textures + .oshape) + EditorCore::resolvePaintGrid → parallel paths/names/kinds (prefab/texture/shape), count and the grid origin_x/origin_y/cell_size |
list_paint_prefabs() | back-compat alias of list_paintable_assets (lists textures + shapes too, not prefabs only; same result shape incl. kinds) |
paint_asset(asset, cell?, position?, suppressed?) | auth — EditorCore::paintTileAtCell (paint a tile into one grid cell as one undo step; same cell replaces its occupant of ANY kind). A .oprefab asset instantiates the prefab; a texture or .oshape paints a BARE tile (a grid-cell sprite/shape object carrying a TileComponent source id — no prefab file) → the painted-root id, kind, snapped col/row/x/y, painted |
paint_prefab(prefab|asset, cell?, position?, suppressed?) | back-compat alias of paint_asset (accepts the source as prefab or asset; paints a texture/.oshape as a bare tile too) |
erase_cell(cell?, position?) | auth — EditorCore::eraseTileAtCell (erase the tile in one grid cell as one undo step, any kind — prefab instance or bare tile) → snapped col/row/x/y, erased |
add_scene_to_levels() | auth — addCurrentSceneToLevels (append the current saved scene to levels.olevels, minting the manifest levels setting the first time; NOT undoable) |
get_project_setting(key?) | a project manifest Setting from the OPEN project (the free-form .orkproj key/values — export.orientation, export.ios.bundleId, …): with key returns value + has; omit key for every setting as parallel keys/values. Read-only |
set_project_setting(key, value) | auth — write one manifest Setting and persist the .orkproj. The authoritative path for export.* config (export.orientation = portrait/landscape/auto, bundle ids, versions): it goes through the editor's IN-MEMORY project so a following Build/export sees it — unlike a raw write_project_file of the .orkproj, which the editor would not pick up. Refused with no project open |
get_safe_area() | the RUNNING game's window size + safe-area insets (notch/rounded corners/home indicator), pixels: window_w/window_h + safe_left/safe_top/safe_right/safe_bottom (streamed on MSG_STATS; -1 until reported, desktop insets 0) |
get_ui_layout() | the RUNNING game's gui widget rects AND resolved text style: parallel ids/rects/styles (each rect "left top width height visible enabled modal", pixels; the three flags are 1/0 — enabled=interactive, modal=part of an active modal dialog. Each style "hasText font textScale colourSet r g b a" — the text style the widget actually draws with after a named [Style] and its own keys merged, so a styling edit is verifiable, not just a geometry one; streamed on MSG_UI_LAYOUT) — combine with get_safe_area to assert every visible HUD widget lies inside the safe box, read modal to assert a dialog is up, or read styles to assert a font/colour/scale landed |
gui_press(id) | auth — synthesize a press on a gui widget by id in the RUNNING game, routed through the REAL input path so modal/disabled semantics apply (a button under a modal scrim does NOT fire; a disabled widget stays inert) (MSG_GUI_PRESS) |
dismiss_modal(id?) | auth — close a modal dialog in the RUNNING game by id, or the topmost one when omitted (MSG_GUI_DISMISS_MODAL) |
send_input(steps) | auth — PLAY the running game: replay a whole input GESTURE (keys, pointer, touch, gamepad, tilt) through the REAL input path, so isKeyDown, the action map, the input script table, the gui hit test and every input listener see it exactly like hardware input (MSG_SEND_INPUT). steps is an ARRAY OF STRINGS applied ONE PER FRAME in order — see the grammar below. ASYNC and FRAME-EXACT: returns accepted + prev_input_seq + input_frames/input_events; poll get_state until input_seq exceeds prev_input_seq, then input_ok=1 means the gesture replayed AND every injected frame was stepped, so runtime_state/runtime_hierarchy/screenshot_game right after it show the RESULT with no sleeping. input_message carries a refusal reason, or one honest note on a success. One gesture at a time; refused (naming the offending step's 1-based index) on a malformed list, with no player, and while the session is PAUSED |
get_breadcrumbs() | the player's on-disk crash trail (pure file I/O — the player may be dead): live (this/most-recent session's breadcrumbs.jsonl text) and previous (the prior session's, rotated aside at boot — the one to read after a crash), one JSON object per line, plus the resolved dir. Two derived fields answer "did the last run die?" straight off the previous trail: crashed ("true"/"false" — true when its LAST entry is a fatal-signal "crash" marker) and crashSignal (the signal name, e.g. "SIGSEGV", empty when not crashed) — the machine-detectable crash verdict a phone can't show as a dialog. Mobile app-lifecycle transitions ride this same trail — "background"/"foreground"/"terminating"/"low_memory" kinds — so no new readback verb is needed to observe backgrounding on device |
get_benchmark_results(file?) | the per-scene performance artifact the player captured when armed for a benchmark run (ORKIGE_BENCHMARK): pure file I/O from the player's writable app dir (its project jail can't reach it, like get_breadcrumbs). Picks the newest benchmark-*.jsonl, or the named file. Returns the raw text, the parsed meta/summary lines, a scenes array (one JSON object per scene — frame-ms min/avg/p50/p95/p99/max, per-phase means, alloc mean+peak, RSS peak, triangle/batch/texture means), scene_count, aborted and the resolved dir/file. Empty when no artifact exists. Schema: Docs/benchmark.md. Starting a run is the existing play verb with ORKIGE_BENCHMARK armed; on-device artifacts are pulled by the CLI harness (adb pull / simctl get_app_container), not MCP |
get_profile() | the RUNNING game's hierarchical CPU frame profile: parallel names/info lists (each info "depth calls milliseconds maxMilliseconds"; depth-0 rows are the canonical tick phases — input scripts events tweens physics load audio present debug render), plus frame_ms and profile_seq (poll until it advances for a fresh frame). A Debug player streams snapshots unprompted (MSG_PROFILE_DATA on the stats cadence); on a Release player the first call arms the profiler (MSG_PROFILE), so call again shortly after. Pair with get_state's alloc_per_frame/alloc_tags to answer "where does the frame go, and what allocates?" |
get_lua_api() | the generated Lua scripting API signature index (inventory text + doc path) — the global tables (world/screen/sound/music/tween/guitween/haptics/cvar/save + the loc global) and core value types, one line per symbol; read-only, needs no project/Play. Embedded from Docs/lua-api.md's generated block (GeneratedLuaApi.h), so an MCP-only agent learns the scripting surface self-contained; see Docs/lua-api.md for conventions and the full type reference |
run_editor_script(name) | auth — run a project EDITOR TOOL (scripts/<name>.editor.lua) once through the editor-tool host — the same tool a human runs from the editor's Tools menu. The tool's editor.* calls route back through this SAME verb handler and its whole run folds into ONE undo step; a tool script error is reported (with its file:line) and leaves NO partial edits. Author a tool with write_project_file, then trigger it here. Returns name and command_count. See Docs/lua-api.md (Editor scripts) for the editor.* surface |
console_tail(count) | the editor EditorConsole line store (includes the player's [remote] lines + script errors during Play) |
list_tests(preset, filter, label) | ctest -N in a build tree → the test names (discovery) |
run_tests(filter, label, preset, build, targets) | async build + ctest → a jobId; poll get_test_results |
get_test_results(jobId) | the structured verdict of a run_tests job |
export_project(platform) | async in-process export (macos/ios-simulator/android/web; the exporter is a library the editor links) → a jobId; poll get_export_results (classic-flavor tree required) |
get_export_results(jobId) | the structured verdict of an export_project job (ok/artifactPath/error) |
Component properties (reflected)
get_component / set_component are GENERIC over the property registry — there is no fixed per-component field list. get_component reports every reflected property of the named component as a name->value field plus the parallel discovery lists properties (names), kinds (int/float/bool/string/ enum/vec3/quat/color/asset/object), hints (enum options label=value,..., or an asset/object kind), readonly and transient (1/0), so an agent discovers the field set with no hardcoded allowlist — a ScriptComponent's exported script properties surface here too. Values are canonical strings: vectors space-separated (vec3 x y z, quat w x y z, color r g b a), bool 1/0, enum the integer value. set_component writes by property NAME (an unknown, read-only or unparseable value is refused without touching the object) and accepts the changed fields either at the top level or inside a properties object, merged into one undo step.
Script component kinds. A behavior script whose file ends in .component.lua is a first-class component kind named after the file (player.component.lua → player); several attach to one object. It flows through the SAME generic verbs with zero kind-specific handling: write_project_file scripts/foo.component.lua makes foo appear in list_addable_components; add_component(id, "foo") attaches it (binding its script file); its declared properties are reflected, so get_component/set_component read and write them by name. Verified end to end by the editor_control self-test.
Flat-colour vector shapes — no new verb
VectorShapeComponent (the flat-colour organic-shape 2D content) needs NO dedicated MCP verb: it rides the generic surfaces above. Author the asset by writing an .oshape directly with write_project_file — it is plain text (a fill r g b a colour + contour N / v x y polylines, holes via hole M), the strong agent path — or import_asset an .svg (the editor cooks it to .oshape in process — no interpreter, no subprocess). Create and place with create_object + add_component (VectorShapeComponent is a reflected type, so it appears in the component registry MCP already exposes) + set_component on transform. Configure shape (the AssetRef, set by resource name like a sprite texture), tint, scale, edgeSoftness, zOrder and visible through get_component / set_component — they are reflected properties, so the single OOBJECT_IMPL registration feeds the inspector, serialization AND MCP at once with no per-surface wiring. Verify with screenshot_game / the scene RTT and run it with play.
Soft, deformable organic shapes need NO new verb either. The soft-body tunables — softBody, wobbleStiffness/wobbleDamping/wobbleAmount, squashAmount, morphClip/morphSpeed/morphLoop — are reflected properties on the SAME VectorShapeComponent, so get_component / set_component (edit mode) and set_runtime_property (a running game) tune them with zero extra plumbing. The scripted drive (impulse, playMorph, stopMorph) is exposed to Lua on self.shape (the component's OFUNC exports), so an agent authors the behaviour by writing the project script with write_project_file. Author a morph set by writing an .oshape with morph NAME blocks directly, or cook one from pose SVGs with tools/shapecook --targets. Observe the deform on the running game: a record_trace captures per-frame object positions (the deformed silhouette rides its node) AND contact events (the impacts that squash it), and get_component reads the live squash/wobble state back through the reflected introspection getters.
Game UI (gui) — authoring vs. readback
Game UI is authored in Lua and project files — screens are built by ScriptComponent scripts (see projects/jumper-lua, projects/roller) or loaded from a declarative .oui layout file (gui:loadLayout, see below). Agents therefore create and edit UI through the existing project-file verbs (write_project_file / read_project_file / list_project_files) and iterate live: overwrite an .oui and call reload_ui(file) — the RUNNING game destroys and rebuilds that screen from the fresh file (clean cutover; a parse failure keeps the OLD screen and surfaces a [remote] error, so a half-broken screen never renders). The editor's .oui watcher fires the SAME reload on a plain file save during Play, exactly like reload_script for .lua. Verify the result with get_ui_layout (the widget rects move); a script re-acquires its now-stale widget handles by subscribing to the ui.reloaded bus event. What agents cannot otherwise observe — the platform safe area and the resolved on-screen widget rects on a real device — is exposed as runtime readback: get_safe_area (the notch/home-bar insets) and get_ui_layout (per-widget pixel rects, plus an enabled and a modal flag per widget), both read-only. Two authoring-adjacent verbs let an agent drive the live UI: gui_press(id) synthesizes a real press on a widget — routed through the actual input path, so a press on a button UNDER a modal scrim does not fire and a disabled widget stays inert — and dismiss_modal(id?) closes a dialog. Together with the modal/enabled flags an agent can assert "the dialog is up, it eats input below, and row X is disabled", then drive the dialog to completion. The full grammar (including the .oui [Modal] / [ToggleGroup] sections and enabled / modal keys) and the widget recipes live in Docs/gui.md.
Driving gameplay: send_input
gui_press drives a WIDGET; send_input drives the GAME. It replays a whole input gesture — key edges, pointer moves/presses, fingers, controller buttons and sticks, a tilt pose — through the same InputManager::injectEvent path the platform's own event loop feeds, so isKeyDown, the named-action map's per-frame edge snapshot, the input script table, the gui hit test and every input listener see synthetic input exactly like hardware input. A gesture travels as ONE call because a round trip per key edge would make every press wall-clock dependent: an agent needs "hold RIGHT for 12 frames" to be frame exact, or its assertion is a coin flip.
steps is an array of strings, one step each, applied one per frame in order:
| step | meaning |
|---|---|
key down <NAME> | press and hold |
key up <NAME> | release |
key press <NAME> [frames] | hold for frames frames (default 1), then release |
pointer move <x> <y> | move the pointer |
pointer down|up <x> <y> [left|middle|right] | press / release a button at a point |
pointer click <x> <y> [button] | move + press, held one frame, release |
touch <id> down|move|up <x> <y> | one finger of a multi-touch gesture at a point |
touch <id> tap <x> <y> | that finger down, held one frame, up |
gamepad button <NAME> down|up | a controller button edge |
gamepad button <NAME> press [frames] | hold it for frames frames (default 1) |
gamepad axis <NAME> <value> | a stick or trigger reading |
tilt angle <radians> | set the simulated tilt angle (0 = upright) |
tilt vector <x> <y> | the same seam, given as a gravity direction (0 -1 = upright) |
wait <frames> | advance the timeline without an event |
Key names are the KC_ spellings without the prefix (SPACE, LEFT, A, F5, WEBBACK — the Android hardware back button), matched case-insensitively, with an optional KC_ prefix and the friendly aliases ENTER/ESC/BACKSPACE/SHIFT/ CTRL/ALT/PAGEUP/PAGEDOWN.
A touch step's <id> is the caller's own finger number (0..9), stable for as long as that finger is down, so a pinch is two interleaved step streams. The game reads the result through the input table (lua-api.md).
Gamepad button names are positional — south, east, west, north, back, guide, start, leftstick, rightstick, leftshoulder, rightshoulder, dpup, dpdown, dpleft, dpright — because the same physical button carries a different letter per vendor; a/b/x/y (and select/menu/l1/r1/l3/r3) resolve as aliases. Axis names are leftx, lefty, rightx, righty, lefttrigger, righttrigger (aliases lx, ly, rx, ry, lt, rt); sticks take -1..1 (+y is down), triggers 0..1, and a value outside that range is refused rather than clamped. A pad that delivers injected events counts as connected, exactly as an injected key counts as pressed — so a controller game is testable on a machine with no controller.
Pointer and touch coordinates are window pixels of the game's drawable — the space get_safe_area reports (window_w/window_h) and get_ui_layout's rects live in. A gesture is bounded (at most 256 steps spanning 600 frames) so one call can never occupy a session.
An injected FRAME is a frame the world advances: the replay is applied at the runtime's frame boundary, before the world steps, so the tick that follows sees the new input state; it HOLDS while the player is paused (and a debug step moves it by one) and picks up where it stopped on resume. The confirmation is therefore a real synchronisation point — when get_state shows input_seq advanced with input_ok=1, every injected frame has already been stepped and the gameplay result is readable immediately.
Honest refusals, all naming the reason: a malformed step list (with the offending step's 1-based index — the editor compiles the same grammar, so a typo never reaches the player), no live player, a PAUSED session (a frozen world would never process the input), and a second gesture while one is in flight. On a DEVICE with a real accelerometer a tilt step is overruled by the sensor: the gesture still succeeds and input_message says so. The verb is transport-neutral — desktop, simulator, adb device and browser sessions all take it, since nothing here touches a filesystem (unlike screenshot_game/record_trace).
The agent playtest loop is send_input → runtime_state (did the object move?) → screenshot_game (does it look right?) → console_tail (did anything complain?). Docs/mcp-workflows.md walks a full one.
Real fonts and vector UI sprites need no new verb either: a runtime font/ sprite atlas is a plain .ogui text asset plus its .ttf/.svg sources under assets/. An agent authors the atlas by writing the .ogui with write_project_file (a [Font.N] section with ttf <asset>/size <designPx>, or a [Sprites] name svg <asset> <designWidth> entry) and brings the source files in with import_asset (or write_project_file for an inline .svg); the runtime bakes them at load. The engine-default font ships with every build, so a project can reference Nunito-Regular.ttf by name with no import at all. Text laid out with these fonts appears in get_ui_layout like any other widget.
Nine-slice sprites, the rect-anchor layout model, layout groups, content-size-fit and the scroll container likewise need no new verb: all are plain-data widget properties an agent sets from the same Lua the game already uses (nine-slice = a 4-int inset suffix on a [Sprites] line + setNineSlice(true); layout = setParent/setAnchorPreset/setOffsets/setAnchoredPosition/setSizeDelta on a widget plus setDesignResolution/setRootSpace on the manager; groups = setLayoutGroup/setGroupPadding/setGroupSpacing/setContentSizeFit; scroll = createScrollView + setScroll).
The declarative .oui file makes this even more agent-native, and it is why read_ui_layout / write_ui_layout verbs are deliberately NOT added: an .oui is a plain text project file, so an agent authors a whole screen — widgets, anchors, pivots, offsets, groups, nine-slice, scroll — by writing it with the existing write_project_file (jailed to the project root), and the game loads it with gui:loadLayout(path). Unlike the level-paint verbs (which mutate live editor scene state that has no file the agent can otherwise write), the .oui path already has the file as its source of truth, so write_project_file + read_project_file cover authoring completely and a dedicated verb would only duplicate them. The core_util/UiLayout descriptor is pure plain data that serialises 1:1 into the .oui, so the file and the Lua setters express exactly the same thing. The resolved absolute rects — the two-pass resolver's output — read back through the existing get_ui_layout (no change needed: the resolve runs before the screens rebuild, so get_ui_layout's per-widget pixel rects already reflect the anchors/groups/scroll offset), so an agent confirms the .oui took effect the same way it verifies any widget (and combines it with get_safe_area for the notch check). The same argument covers STYLING: a .oui font / textColor / textScale key and a [Style NAME] bundle are ordinary text an agent writes with write_project_file, and get_ui_layout's parallel styles list reports the RESOLVED result per widget (hasText font textScale colourSet r g b a), so the style-first / own-keys-override precedence is assertable with no new verb.
The one UI verb that does earn its place is preview_ui — because it fills a gap the readback verbs cannot: seeing a screen at a chosen device context WITHOUT booting the game. get_ui_layout reports the running game's rects at the running window's size; preview_ui renders a project .oui through the same real gui stack into an offscreen target at a SIMULATED device (resolution + content scale + safe-area notch), and returns a screenshot plus the resolved rects for that context (or a matrix of contexts in one call). It is the agent's half of the collaborative UI loop: author a screen with write_project_file, preview_ui it across a phone and a tablet, read the rects to check the layout holds on both, iterate. The editor_control self-test drives exactly this loop (write → preview → edit → preview → assert the rects moved; a missing file errors). It is Ogre-Next only (the offscreen 2D composition the preview needs is a Next-flavor capability — see Docs/render-abstraction.md); the classic editor reports an honest error.
Its scene-side sibling is preview_game: the same "see it before playing" idea, but for the whole SCENE rendered through its own CameraComponent at a device preset — the agent's half of the look-dev loop the human drives from the editor's Preview tab (both share the one GamePreviewStage, so an edit an agent makes shows live in the tab). preview_game answers "what will this look like on the device?" without booting the game: it renders the current scene through the scene camera at a device preset (from the pure core_util/DevicePreset table — real device names with real logical resolutions / scales / safe-areas; the drawn device frame is our own generic silhouette, never third-party art), optionally composites a project .oui screen over it and optionally advances material-parameter animation (water scroll) for the shot, all with the world kept dormant. The scene render is both flavors; the .oui overlay is Ogre-Next only like preview_ui. The editor_control self-test drives it end to end (a scene camera, the overlay on Next, material animation, a bad preset error, and auth enforcement).
The machine-checkable "HUD respects the notch" assertion is: for every visible widget from get_ui_layout, its rect lies inside [safe_left, window_w-safe_right] × [safe_top, window_h-safe_bottom] from get_safe_area (the player_safearea_device ctest runs exactly this against a booted iPhone 16 simulator).
GUI animation — Lua/.oui-authored, no new verb
Widget animation (the guitween table, transition in the .oui, cascading group alpha, button press feedback, scroll momentum) is authored in Lua and the declarative layout, so an agent drives it with the existing write_project_file / script-authoring verbs — no dedicated animation verb. Read-back needs none either: get_ui_layout reports the RESOLVED rects, so a poll mid-animation shows a widget's interpolating position/size (a move/size tween drives the layout inputs the resolver already reflects), and screenshot_game captures the scaled/rotated/faded frame. The pure animation logic is unit-tested headlessly (GuiAnimationTests, TweenTests loops) and the demo_gui_matrix selfcheck exercises the whole surface on both flavors.
Haptics, tilt calibration, screen fades — Lua-authored, no new verb
Three device/presentation features are reachable through the SAME authoring-in-Lua + readback path as game UI, so they need no dedicated MCP verb:
-
Haptics (
haptics.play(strength, ms)/haptics.pattern(name)/haptics.isAvailable()/haptics.setEnabled()) — phone-body vibration, a device-only effect (desktop is an honest no-op). An agent authors the calls in aScriptComponentviawrite_project_fileand iterates withreload_script; there is no headless "did it buzz" to read back. -
Tilt calibration (
input:calibrateTilt()/input:clearTiltCalibration()/
input:getTiltCalibration()) — captures the current pose as neutral. Authored in Lua (a settings "Calibrate" button); the resulting offset is a reflected-ish input value the game reads throughinput:getTilt(). -
Screen fades (
screen.fadeOut(secs)/screen.fadeIn(secs)/screen.loadScene(path, out, in)/screen.setFadeColor/screen.isFading) — a full-screen wipe over a scene switch, authored in Lua and observable viascreenshot_game(the overlay renders into the captured frame).
All three are driven by writing/reloading project scripts and confirmed with the existing readback verbs (screenshot_game, console_tail, get_breadcrumbs), so the standing "every feature reachable or justified" rule is met without a new tool.
Game-support pack (save / camera fit / screen shake / time scale / text entry) — no new verb
The game-support features follow the same authored-in-Lua + reflected-property pattern, so none needs a dedicated verb:
-
Persistence (
save.set/getNumber/getBool/getString/has/remove/flush) —authored in a
ScriptComponentviawrite_project_file, iterated withreload_script. The on-disk store is a plain file in the writable app dir; an agent confirms a write withget_breadcrumbs/console_tail(the store logs its flush) or reads a value back through the game's own HUD viascreenshot_game. -
2D camera fit — the
CameraComponentfitMode/designWidth/designHeightare REFLECTED properties, so they are already authored and read back through the genericget_component/set_component(edit mode) andset_runtime_property(live) — no bespoke verb, exactly like every other component property. The script-drivenengine:setCameraOrthographicFitis a Lua call authored the same way assetCameraOrthographic. -
Camera authoring — a camera is a plain GameObject carrying a
CameraComponent. In the editor: GameObject > Create Camera (also the Hierarchy right-click menu / native menu) spawns one at the current view pose (the new camera sees what the author sees), the selected camera draws a frustum gizmo in the Scene view (a wireframe view volume — perspective cone or orthographic box honouringfitMode), and every new scene ships with a defaultMain Camera. A camera-less scene still plays through the player's default-window-camera fallback. Over MCP there is NO bespoke verb:add_objectthenadd_componentwith"CameraComponent"adds one by name (the reflected component registry), andset_componentdialsprojectionMode/orthoSize/fitMode/designWidth/designHeight— the same generic path as any component. The camera round-trips throughsave/reload like any object. -
Screen shake (
screen.shake/stopShake/isShaking) and **timescale** (
world.setTimeScale/getTimeScale) — runtime Lua calls, authored +reload_script-iterated; the shake is observable inscreenshot_game, and time scale is confirmed by watchingruntime_statevalues advance at the scaled rate (or not, at 0 = hitstop). -
Text entry (
createTextEntry, `getText/setText/setPlaceholder/setMaxLength/wasSubmitted
) — a gui widget, authored in Lua like every other widget and read back withget_ui_layout/screenshot_game` (the "authoring vs. readback" rule above already covers game UI).
Test runner (the evidence loop)
run_tests is the close-the-loop primitive: edit, run the relevant test, read a STRUCTURED verdict, iterate. Everything in Orkige is already self-checking (Catch2 units + self-checking apps that exit non-zero on failure), so this is cheap, deterministic evidence.
-
list_tests(filter?, label?, preset?)runsctest -Nand returnstests(the names) so an agent can find, e.g., the selfcheck for the project it is editing.
filteris a ctest-Rname regex,labela-Llabel (unit/integration).device-labelled (simulator/emulator) tests are never listed. Synchronous (fast). -
run_tests(filter?, label?, preset?, build?, targets?)is async: itreturns
{ accepted:"1", jobId }immediately and does the work on a worker thread. Withbuildunset/true it firstcmake --builds the tree (targetsscopes it); a build failure short-circuits — no ctest runs and the result isbuildFailed:"1"+buildErrors(the compiler output tail), so an agent's first question ("did it compile?") is answered directly. Thenctestruns, filtered byfilter(-R) and/orlabel(-L);device-labelled tests are ALWAYS excluded (a run never boots a simulator or emulator). Passbuild:"0"to test an already-built tree as-is (fast). -
get_test_results(jobId)returnsstatus:"running"until the run finishes,then
status:"done"withtotal/passed/failedcounts and, for each failure, the index-aligned listsfailed_names,failed_durations,failed_logtails(each logtail is the last ~40 lines of that test's captured output — the "why did it fail" material). All counts cross as strings, per the DebugMessage convention. -
presetselects the build tree: empty /desktop/unit/next= thiseditor's own build;
desktop-classic(or abuild/dir name, or an absolute path) = another tree. Build/ctest paths are this editor build's own baked constants (CMAKE_COMMAND/CMAKE_CTEST_COMMAND).
Example loop — edit a project script, then verify with its selfcheck:
// 1. discover the acceptance gate for the project you are editing
tools/call list_tests { "filter": "jumper_lua" }
// → { "tests": ["player_jumper_lua_selfcheck_next"], "count": "1" }
// 2. after editing scripts, run it (authed; build the tree first)
tools/call run_tests { "filter": "player_jumper_lua_selfcheck" }
// → { "accepted": "1", "jobId": "a1b2..." }
// 3. poll for the verdict
tools/call get_test_results { "jobId": "a1b2..." }
// → { "status": "running" } // keep polling
// → { "status": "done", "total": "1", "passed": "0", "failed": "1",
// "failed_names": ["player_jumper_lua_selfcheck_next"],
// "failed_logtails": ["...assertion: player never left the ground..."] }
// read the logtail, fix the script, go back to step 2.
// a build that does not compile short-circuits with the compiler errors:
// → { "status": "done", "buildFailed": "1",
// "buildErrors": "player.lua:12: ... error: ..." }
Authoring a project over MCP
get_component/set_component mutate objects already in a scene, but a scene is built out of files an agent also has to author: game logic lives in Lua scripts, behaviour in prefabs, tuning in config assets. write_project_file, read_project_file, list_project_files, import_asset and the two prefab verbs close that gap so an MCP-only agent never needs a separate filesystem tool.
Path safety. Every file verb is JAILED to the open project's root: the path is project-root-relative, absolute paths are refused, and any .. or symlink escape is rejected (the same lexical containment AssetDatabase::resolveInsideRoot uses for asset imports, plus an absolute-path refusal and a weakly-canonical symlink-escape re-check). import_asset is the one exception — its source is by nature outside the project (it copies a file IN) — so it needs auth, exactly like a human dragging a file into the editor.
Config assets (input.oactions / physics.olayers / levels.olevels) are project-root-relative files referenced from the manifest Settings, so they are authored with plain write_project_file — there is no dedicated verb. The editor does not cache them in edit mode (it re-reads them at Play/export time and the Inspector reloads the physics layers on open_project), so no reload hook is needed; a running player picks up a rewritten config on its next Play.
Scripts + hot-reload. Writing a scripts/*.lua file during a live Play session needs no explicit reload verb: the editor already polls scripts/ (~4 Hz, watchProjectScripts) and fires the hot-reload on any change, so an MCP write is picked up like an editor-side edit. reload_script (see below) is still there to force a reload on demand.
The develop → verify loop:
// 1. write the logic
tools/call write_project_file { "path":"scripts/player.lua",
"content":"function update(dt)\n self.y = self.y + dt\nend\n" }
// → { "path":"scripts/player.lua", "bytes":"41" }
// 2. discover / read files back
tools/call list_project_files { "dir":"scripts", "glob":"*.lua" }
// → { "names":["player.lua"], "paths":["scripts/player.lua"], "types":["file"] }
tools/call read_project_file { "path":"scripts/player.lua" } // → { "content":"..." }
// 3. bring an outside asset in (sidecar minted, stable id returned)
tools/call import_asset { "sourcePath":"/tmp/hero.png" }
// → { "path":"assets/hero.png", "assetId":"5f2c..." }
// 4. capture a subtree as a reusable prefab, then stamp instances of it
tools/call create_prefab { "objectId":"Enemy", "path":"assets/Enemy.oprefab" }
// → { "id":"Enemy", "path":"assets/Enemy.oprefab", "assetId":"a1b2..." }
tools/call instantiate_prefab { "path":"assets/Enemy.oprefab", "parent":"Spawner" }
// → { "id":"Enemy 2" }
// 5. run the project's selfcheck for evidence (the loop above, run_tests)
tools/call run_tests { "filter":"player_jumper_lua_selfcheck" }
Then verify with run_tests + a screenshot (or screenshot_game during Play).
Debugging a running game
During Play the editor spawns the player as a separate process and talks to it over the ONE player debug protocol (core_debugnet). A distinct family of tools bridges that link so an agent can drive and inspect the RUNNING game — the editor-side MCP endpoint never opens a second player port. This is the DEBUG half of the develop→run→test→debug loop: change something, watch it live, verify with a screenshot.
Edit world vs. running game. list_hierarchy / get_object / get_component / set_component ALWAYS operate on the EDITOR's world, even while Play is running (editing the scene mid-play stages changes for the next Play; it does not touch the live game). The runtime_* tools serve the LIVE player instead. Each returns isError ("no live player - start Play first") when nothing is playing, so the mode is never ambiguous.
-
runtime_hierarchyreads the running game's live tree (ids/parents/active, parallel lists; plusscene,selected,play_mode). The player streams it on change; this returns the latest snapshot. -
runtime_select(id)picks which running object streams its component state(the debug protocol streams the SELECTED object only), then
runtime_statereturns that stream:object(the streamed id),ready("1"once it matches the selection), and the parallel listsproperties(the"<Component>.<property>"keys),values,kinds,hints,readonly. The stream is asynchronous (~15 Hz), so select, then pollruntime_stateuntilready="1". -
pause/resume/stepgate the running game's world stepping (renderingand the protocol stay live);
stepadvances exactly one fixed physics tick while paused. -
set_runtime_property(id, component, property, value)writes ONE reflectedproperty on the running game through the player's reflected setter — it takes effect immediately and is NOT undoable (contrast
set_component, which is the undoable edit-world write). A bad name/value is reported as a[remote]error line rather than failing the call, matching the live protocol's one-way write. -
set_cvar(name, value)tunes a console variable on the running game(
CVarManager, fires itsonChange).reload_script(id?)hot-reloads Lua (compile-before-swap; one object or all) — a broken edit keeps the old instance and surfaces a[remote] SCRIPT ERROR. -
console_tailalready includes the running player's forwarded log (the[remote]lines) and its script errors — the player forwards its engine log over the debug protocol and the editor folds it into the same Console store, so no separate remote-log verb is needed. -
Script breakpoints pause the running game MID-STATEMENT inside a Lua
script (a distinct state from the frame-boundary
pause):set_breakpoint/clear_breakpoint/list_breakpointsmanage the per-project set (pushed to the player live),get_debug_statepolls the asynchronous break-hit (paused file/line + call stack),get_localsreads a paused frame's locals/upvalues (tables expand on explicit request, bounded), anddebug_continue/debug_step_in/debug_step_over/debug_step_outrelease the break.debug_break_nextneeds no breakpoint at all — it pauses into wherever the scripts run next (the "where does code even run right now?" orientation move), the async break-hit landing onget_debug_statejust like a breakpoint.set_break_on_errorsarms a break AT an uncaught script error — the crash pauses the game at the fault (get_debug_state'sbreak_errorcarries the message; file/line/stack/locals show the real crash), and Continue still lets the honest failure flow (the instance disables). The worked loop is inDocs/mcp-workflows.md; the design (hook lifecycle, pause semantics, the web/noscript refusals) inDocs/script-debugging.md. These are the SAME breakpoints the editor's Script panel shows — an agent and the human share one store. -
screenshot_game(path)captures the RUNNING game's next rendered frame (themost-cited verification primitive). Desktop play only — the path lives on the player's filesystem, which the editor shares on desktop. It is ASYNC: the tool returns
{ accepted:"1", path, prev_screenshot_seq }; the player captures the next frame and confirms, andget_statethen carriesscreenshot_seq(bumped on each confirmation),screenshot_okandscreenshot_path. Pollget_stateuntilscreenshot_seqexceeds the returnedprev_screenshot_seq. -
record_trace(path, seconds?, everyNth?, objects?)is the TEMPORAL sibling ofscreenshot_game— evidence for behaviour that unfolds over time, in the form an agent can actually READ: a.jsonlflight recorder (one JSON object per line), NOT pixels. EveryeveryNthrendered frame (default 2) it writes a sample line —{"t", "frame", "dt", "objects":[…]}— carrying, per named object, its worldpos,vel(only when a rigid body exists),activeandvisible(in the window camera's view). Event lines interleave as they occur:contactBegin/contactEnd(both object names),sceneLoad,scriptError,warning(warning-and-above log lines) AND the message-bus events emitted that frame — the ones a script raised withevents.emit(name, payload)plus the engine mirrors (gui.clicked,physics.contactBegin,app.pause, …), each a line named for the event with its payload's top-level scalar fields (bus events are Lua-authored, so the trace is their readback). Records for up tosecondswall-clock (default 5, capped at 60);objectsnarrows to a comma-separated id/name allowlist. The trace is byte-capped (~2MB) with an honest{"truncated":1}marker line if hit. Desktop play only, and ASYNC likescreenshot_game: it returns{ accepted:"1", path, prev_record_seq };get_statethen carriesrecording(in progress),record_seq(bumped on each confirmation),record_okandrecord_path. Poll untilrecord_seqexceeds the returnedprev_record_seq, or callstop_recording()to end it early. The dt field lets an agent assert on performance; the position series lets it assert on movement — e.g. "the player's y rose then fell" (a jump).
get_state is the poll target for all of the above: besides the editor snapshot it carries remote_connected, remote_scene, remote_selected, remote_object_count, the screenshot_* fields and the recording/record_* fields while a play session is up. It also carries the running game's memory footprint — mem_rss (the process resident set size in bytes) and mem_rss_peak (the session high-water mark) — streamed by the player a few times a second (MSG_STATS). Both read "-1" until the first reading arrives (or on a platform without a memory query); read mem_rss against mem_rss_peak, or the mem field on trace sample lines (below), to spot unbounded growth.
Example loop — inspect and poke the running game, then capture evidence:
tools/call play {} // → { accepted:"1", play_mode:"launching" }
tools/call get_state {} // poll → play_mode:"playing", remote_connected:"1"
tools/call runtime_hierarchy {} // → { ids:["Player", ...], ... }
tools/call runtime_select { "id": "Player" } // authed
tools/call runtime_state {} // poll → ready:"1", values:["1 0 0", ...]
tools/call pause {} // authed → get_state play_mode:"paused"
tools/call set_runtime_property { // authed; live write
"id":"Player","component":"TransformComponent","property":"position","value":"2 3 4" }
tools/call screenshot_game { "path":"/tmp/frame.png" } // → { accepted:"1", prev_screenshot_seq:"0" }
tools/call get_state {} // poll until screenshot_seq > 0, screenshot_ok:"1"
tools/call resume {} // authed; let it run while we record
tools/call record_trace { "path":"/tmp/run.jsonl", "seconds":3, "everyNth":2 } // → { accepted:"1", prev_record_seq:"0" }
tools/call get_state {} // poll until record_seq > 0, record_ok:"1"; then READ /tmp/run.jsonl
tools/call stop {} // authed → back to edit mode
Running: what plays, and where
play is the RUN half of the loop. Two optional arguments control WHAT runs and WHERE:
-
sceneopens a scene into the editor first, then plays it. It isproject-relative and jailed inside the open project (an absolute path or a
../symlink escape is refused); with no project open the raw path is used, as foropen_scene. Opening a different scene discards the current unsaved edit world, so it honors the dirty-state policy — passforce:"1"to override. -
targetpicks the device.""/"desktop"runs the local player;otherwise pass an id from
list_play_targets— an iOS simulator UDID, an adb serial or"browser". A shutdown simulator boots asynchronously (the toolbar's boot flow), soplayreturns{ accepted:"1" }and you pollget_state(play_modewalkslaunching→playing). Native-module projects are desktop-only. -
"browser"is an export-serve-open that becomes a LIVE play session oncethe page loads: the editor runs a
webexport, serves the artifact directory on a loopback port, opens the default browser at a URL carrying?env.ORKIGE_DEBUG_CONNECT=127.0.0.1:<servePort>— and waits. The page's runtime dials that endpoint (a page cannot listen, so the direction reverses; its socket emulation rides a WebSocket the serve port upgrades) and the session becomes a desktop-like live session:runtime_hierarchy,runtime_state,pause/resume/step,set_runtime_property,set_cvarand the[remote]Console lines all work over the ONE debug protocol. Pollget_state—browser_play_statuswalksexporting→serving→connected(orfailed) andbrowser_play_urlcarries the page URL; a headless agent drives its own browser at that URL (the shell page maps?env.NAME=VALUEquery params onto the module environment, so the native automation hooks work —Docs/web-export.md). A page that never connects degrades honestly: the waiting session times out back to edit mode, the status returns toservingand the tab runs standalone.stopsends quit over the link — the page's game loop exits (the editor cannot close a tab; the finished page stays). What does NOT work over a browser link, each refusing with an honest error:screenshot_gameandrecord_trace(the page's in-memory filesystem never reaches the editor's disk) andreload_script/reload_ui/reload_anim(the page runs its packaged export snapshot — stop, re-play, and the fresh export picks the edit up). Gated (target_states:"gated") until the web-release preset built the wasm player.
list_play_targets (a read) enumerates exactly what the editor's target picker shows: target_count plus the parallel lists target_kinds (desktop/browser/ios-simulator/ios-device/android), target_ids (what you pass to target), target_names and target_states (ready/booted/shutdown/gated/device).
tools/call list_play_targets {} // → target_ids:["desktop", "<udid>", ...]
tools/call play { "scene":"scenes/level1.oscene", "target":"desktop" } // authed → { accepted:"1", target:"desktop" }
tools/call get_state {} // poll → play_mode:"playing"
Reading build errors (compile-on-Play). For a native-module project, Play first compiles the module; a failed build stays in edit mode and launches nothing. The [build] lines stream into the Console (read them with console_tail), and get_state carries the STRUCTURED signal an agent acts on: build_status (none/building/ok/failed), build_target, and — on a failure — build_errors (the compiler-diagnostic tail, kept after the session reverts to edit mode so you can read it post-hoc). Poll get_state after play: build_status:"failed" with a non-empty build_errors is the "fix the compile" signal; build_status:"ok" then play_mode:"playing" is the "it launched".
A play that is REFUSED before any compiling reports the reason in its own error text (and as the same build_errors sentence), because those refusals are not compile problems and have different fixes: this editor has no engine to build against (the SDK for this build is not installed — a source-built editor uses its own build tree, a downloaded one an installed SDK pack, Docs/sdk-pack.md), or the machine has no cmake/ninja on its PATH. Orkige ships the engine, never a toolchain, so those two are said separately.
Exporting a project
export_project(platform) packages the open project as a distributable through the same pipeline as the editor's Build menu (tools/exporter, run in process). platform is macos, ios-simulator or android. It is async (a multi-minute job): it returns { accepted:"1", jobId }; poll get_export_results(jobId) — status:"running" until it finishes, then status:"done" with ok ("1"/"0"), the artifactPath (the built .app/ .apk) on success or the error on failure, plus the exporter's outputTail.
Where the engine pieces come from depends on the editor answering. An editor built in the engine source tree packages a preset build tree — either render flavor, since the exporter bundles that tree's own engine media (the RTSS set on classic, the Hlms templates on next): export_project checks that tree up front and returns an honest structured error — WITHOUT running the exporter — when the platform's preset was never built. A distributed editor has no build tree and packages the engine payload it carries inside itself instead; there it exports the desktop app and refuses ios-simulator/android with a message saying that platform's player only comes from a source build. Either way engineBuild in the reply names what the export packaged from. A native-module project exports for the desktop and the iOS simulator, and needs the installed SDK pack for that target the same way Play does; the refusal names whichever prerequisite is missing — Docs/sdk-pack.md, Docs/editor-distribution.md. A project with no compiled C++ never needs a pack for any platform.
tools/call export_project { "platform":"macos" } // authed → { accepted:"1", jobId:"..." }
tools/call get_export_results { "jobId":"..." } // poll → status:"done", ok:"1", artifactPath:".../MyGame.app"
Icon / launch-screen / signing config — no new verb. The exporter generates a per-project app icon (from the manifest export.icon, or a neutral engine default) and launch screen, and gates a signed --platform ios device build on a resolvable identity + provisioning profile. Every new failure mode — a missing/too-small export.icon, an iconutil/aapt2 failure, an absent signing identity — is emitted by the exporter to stdout and flows through the existing get_export_results error + outputTail fields unchanged. No new tool and no new schema field are needed; the export verb already surfaces these honestly. (A signed ios device platform is not yet an export_project enum value — it lands as a one-line enum addition once a device player build exists.)
Store packaging stays CLI-only, deliberately. The store-submittable platforms (android-aab — a release-signed Android App Bundle; ios-ipa — a distribution-signed .ipa) are NOT export_project enum values. They require machine-local secrets — a release keystore + its passwords, an Apple distribution certificate + App Store profile — that a remote agent does not (and should not) hold, so a store verb over MCP could never do more than gate. The gating logic itself (version-code/keystore config validation, the honest refusal when a credential is missing) lives in tools/exporter/ExportSettings.h and is exercised cert-free by its --selftest; run store packaging from the shell, where the secrets live — orkige_export inside the engine repository, orkige_editor export on an installed Orkige (Docs/editor-cli.md). See Docs/store-release.md.
Dirty-state policy
Destructive verbs (new_scene, open_scene, open_project, new_project, close_project) refuse to clobber an unsaved scene: they return isError unless the scene is clean or the call passes force="1". Automated runs never touch the user's recents (the editor's gRecordRecents/automatedRun suppression).
Tests
-
editor_control(ctest, integration): the editor hosts its MCP endpoint on anephemeral in-process port and, on a worker thread, drives a raw TCP socket through a real MCP conversation —
initialize,notifications/initialized,tools/list, thentools/calls:create_object(authed, + verify it appears inlist_hierarchy), an AUTH-REJECTEDcreate_object(a mutation with no bearer token), and ascreenshotto a temp path (+ verify the file was written) — asserting MCP-compliant JSON-RPC responses (id echo, result shape) at every step. It also drives the test-runner tools:list_tests(a known core unit test must appear),run_tests+get_test_resultson ONE already-built unit test (build:"0", asserting the structured pass tally), and a throwaway failing CTest tree (built on the fly) so thefailed_*lists + logtail parse are exercised on a real failure without touching the real suite. It also proves the runtime debug tools' NEGATIVE paths headlessly:tools/listadvertises them, the runtime READS (runtime_hierarchy/runtime_state) error cleanly with no player, and every runtime MUTATION is rejected without a bearer token. Finally it drives the AUTHORING tools against a throwaway project it opens withnew_project: awrite_project_file→read_project_fileround-trip (asserting CRLF is normalized to LF), two jail violations REFUSED (an absolute path and a..escape, verifying nothing was written outside the root), an auth-rejectedwrite_project_file,list_project_files(the written script appears under a*.luaglob),import_assetfrom a temp file (a stable id is minted, an unauthenticated import rejected), acreate_prefab→instantiate_prefabround-trip (the.oprefablands on disk and the fresh instance appears inlist_hierarchy), and the GRID-PAINT loop over that same prefab (list_paint_prefabslists it and reports a grid, an auth-rejectedpaint_prefab, thenpaint_prefabinto a cell — the painted root appears inlist_hierarchy—erase_cellremoves it andundobrings it back) PLUS the bare-tile path (a probe texture is written,list_paintable_assetssurfaces it as atexture,paint_assetpaints it as a bare tile,erase_cellremoves it). It also drivesrun_editor_scriptend to end:write_project_files ascripts/*.editor.luatool, an AUTH-REJECTEDrun_editor_script, then the authed run — asserting the object the tool authored appears inlist_hierarchy. It then exercises PREFAB EDIT MODE over MCP: an auth-rejectedopen_prefab, the authedopen_prefab(the stage roots at the file stem,get_statereportsedit_context=prefab, and the swapped-out scene object is gone fromlist_hierarchy), an ordinary editing chain against the stage (create_object→reparent_object→set_componenton a child, read back), asave_sceneREFUSED with the prefab-mode error,save_prefab, a policy-lessclose_prefabrefused, thenclose_prefab {discard}— assertingget_statereturns toedit_context=sceneand the swapped-out scene object is back inlist_hierarchy. It also covers the RUN tools:list_play_targetsreports the desktop target, andexport_projectrefuses an unauthenticated request, a no-project request, an unknown platform and (on a next-flavored editor tree) the classic-pinned flavor check — all as fast structured refusals, leaving real exports to theexport_*ctests. Proves the whole C++ MCP endpoint with no Python. -
editor_control_debug(ctest, integration; needs the built player): the samesocket-driven MCP client, but the RUNTIME DEBUG loop end to end against the live player —
play { scene, target }(saves a fixture scene, clears the edit world, then plays that scene on the desktop target — a runningCube1proves the scene path took effect), thenruntime_hierarchy/runtime_select/runtime_state(live inspection),pause+step,set_runtime_property(a live Transform write read back throughruntime_state),screenshot_game(the running-game frame, verified non-empty on disk viaget_state'sscreenshot_seq/screenshot_ok),record_trace(records the running game while nudging its Transform, then parses the written.jsonlline by line and asserts the moving object's sampled position changes across samples and every sample carries a positivedt, viaget_state'srecord_seq/record_ok),reload_script, an auth-rejected mutation on the LIVE player, thenstopand a clean revert to edit mode. Every step runs through the MCP tools, exactly as an agent would drive them. -
JsonTests/HttpServerTests(ctest, unit): the nested-JSON codecround-trip + malformed-input safety, and the hand-rolled HTTP/1.1 framing (request parsing, keep-alive pipelining, case-insensitive headers, junk tolerance) — both headless, in the unit and desktop presets.