Orkige Help DownloadOrkige

MCP agent workflows

Worked walkthroughs for an AI agent driving the Orkige editor over its MCP endpoint. This is the tutorial companion to Docs/mcp.md — that document is the reference (endpoint, auth, transport, the full tool table and per-tool semantics); this one shows the tools composed into the loops an agent actually runs.

Every call below is a JSON-RPC tools/call against POST /mcp. The wire form is

// request
{ "jsonrpc":"2.0", "id":7, "method":"tools/call",
  "params": { "name":"<tool>", "arguments": { ... } } }
// result content (structuredContent shown; a refused verb comes back isError)
{ "jsonrpc":"2.0", "id":7, "result": { "structuredContent": { ... } } }

The walkthroughs elide the envelope and show just <tool> { arguments } → { reply }.

Evidence-first: never claim success you did not read back

The editor tools are built so an agent can observe, not guess. A mutation returning without isError means the verb was accepted — it does NOT mean the game now behaves the way you intended. Before reporting a step done, read the result back through the matching read tool:

  • authored a file → read_project_file / list_project_files
  • changed a component → get_component (edit world) or runtime_state (live game)
  • ran the game → get_state (play mode), runtime_hierarchy, console_tail
  • verified behaviour → run_tests + get_test_results, or screenshot_game

    (a frame) / record_trace (a .jsonl of motion + events over time, read back)

All scalar fields cross the wire as STRINGS ("1"/"0" for flags), matching the debug-protocol convention. The async tools (play, run_tests, export_project, screenshot_game, record_trace) return an accepted acknowledgement and are POLLED — treat "accepted" as "started", and keep polling the stated field until it settles. Mutations need the Authorization: Bearer <token> header; pure reads are open.


1. Author a feature

Write a script, attach it to an object, run the game, confirm it does what you meant, then iterate on the script with hot-reload — all without leaving the MCP session. Assumes a project is already open (open_project).

// 1. write the game logic as a project file (jailed to the project root, LF)
write_project_file {
    "path":"scripts/spinner.lua",
    "content":"function update(dt)\n  self.rotationY = self.rotationY + dt\nend\n" }
//   → { "path":"scripts/spinner.lua", "bytes":"57" }

// 2. read it back — the evidence the write landed as intended
read_project_file { "path":"scripts/spinner.lua" }
//   → { "path":"scripts/spinner.lua", "bytes":"57", "content":"function update..." }

// 3. attach a ScriptComponent to the object, then point it at the script.
//    set_component writes reflected properties by NAME; run get_component first
//    if you are unsure of the exact names (here: 'script').
add_component { "id":"Cube1", "component":"ScriptComponent" }        // authed → {}
set_component { "id":"Cube1", "component":"ScriptComponent",
    "properties": { "script":"scripts/spinner.lua", "enabled":"1" } } // authed → {}
get_component { "id":"Cube1", "component":"ScriptComponent" }         // verify:
//   → { "script":"scripts/spinner.lua", "enabled":"1",
//       "properties":["script","enabled",...], "kinds":["asset","bool",...] }

// 4. run it. play is async — poll get_state until it settles.
play {}                                     // authed → { "accepted":"1", "play_mode":"launching", "target":"desktop" }
get_state {}                                // poll → { "play_mode":"playing", "remote_connected":"1", ... }

// 5. evidence the script is live on the running object
runtime_hierarchy {}                        // → { "ids":["Cube1",...], "play_mode":"playing", ... }
runtime_select { "id":"Cube1" }             // authed → { "selected":"Cube1" }
runtime_state {}                            // poll until ready="1":
//   → { "object":"Cube1", "ready":"1",
//       "properties":["TransformComponent.orientation",...], "values":["1 0 0 0",...] }
console_tail { "count":50 }                 // → { "lines":[...,"[remote] ..."], "levels":[...] }

// 6. edit the script — a write to scripts/*.lua during a live Play is picked up
//    by the editor's scripts/ watcher and hot-reloaded automatically (or force
//    it with reload_script).
write_project_file {
    "path":"scripts/spinner.lua",
    "content":"function update(dt)\n  self.rotationY = self.rotationY + dt * 3\nend\n" }
//   → { "path":"scripts/spinner.lua", "bytes":"61" }
reload_script { "id":"Cube1" }              // authed (optional; the watcher also fires) → {}

// 7. confirm the new behaviour, then stop
runtime_state {}                            // poll → orientation now advancing faster
stop {}                                     // authed → {}

A reload that fails to compile keeps the OLD instance running and surfaces a [remote] SCRIPT ERROR — so always read console_tail (or runtime_state) back after a hot-reload rather than assuming the swap took.


1b. Atomic edits: one undo step for a remote client

A multi-step edit should land as ONE undo step (and be undone as one), so a human who dislikes the result reverts it with a single Cmd+Z — and a failed build-up leaves NO partial edits. Wrap the edits in begin_transaction … end_transaction: every mutating verb in between folds into one CompositeCommand on commit=true, or unexecutes wholesale on commit=false. This is the same one-undo primitive an .editor.lua tool gets (## 6), now reachable from ANY MCP client.

begin_transaction {}                        // authed → {}   (get_state.transaction_open == "1")
create_object { "id":"Turret", "mesh":"cube" }                 // authed → { "id":"Turret" }
create_object { "id":"TurretBarrel", "mesh":"cube" }           // authed → { "id":"TurretBarrel" }
reparent_object { "id":"TurretBarrel", "parent":"Turret" }     // authed → {}
set_component { "id":"Turret", "component":"TransformComponent",
    "properties": { "position":"4 0 0" } }                     // authed → {}
end_transaction { "commit": true }          // authed → { "committed":"1", "command_count":"4" }
// one undo now reverts ALL FOUR edits together:
undo {}                                     // authed → {}   (Turret + barrel gone in one step)

end_transaction { "commit": false } instead unexecutes everything since begin — the escape hatch when a mid-sequence read comes back wrong. Two rules: a transaction is refused if one is already open (begin) or none is (end), and it auto-aborts (rolling back, logging one Console line) if the editor switches scene/project/prefab, starts Play, or shuts down under it — so keep it short-lived and check get_state.transaction_open if in doubt. The fold is origin-blind: a manual editor edit the owner happens to make between two of your requests is adopted into the transaction too.

Because it is plain JSON-RPC over HTTP, any language drives it. A dependency-free python3 client (stdlib urllib only — no MCP SDK):

import json, urllib.request

URL, TOKEN = "http://127.0.0.1:8765/mcp", open("/path/to/token").read().split()[-1]

def call(tool, args=None, _id=[0]):
    _id[0] += 1
    body = json.dumps({"jsonrpc": "2.0", "id": _id[0], "method": "tools/call",
                       "params": {"name": tool, "arguments": args or {}}}).encode()
    req = urllib.request.Request(URL, body, {
        "Content-Type": "application/json",
        "Authorization": "Bearer " + TOKEN})           # mutations need the bearer
    res = json.load(urllib.request.urlopen(req))["result"]
    if res.get("isError"):
        raise RuntimeError(res["content"][0]["text"])   # honest failure, not silent
    return res.get("structuredContent", {})

call("begin_transaction")
try:
    call("create_object", {"id": "Turret", "mesh": "cube"})
    call("create_object", {"id": "TurretBarrel", "mesh": "cube"})
    call("reparent_object", {"id": "TurretBarrel", "parent": "Turret"})
    call("end_transaction", {"commit": True})           # one undo step
except Exception:
    call("end_transaction", {"commit": False})          # roll back, no partial edits
    raise

2. The test loop

Close the loop with structured evidence: make a change, run the relevant test, read a machine-parseable verdict, fix, rerun until green. This is the cheapest form of proof because every Orkige test self-checks and exits non-zero on failure.

// 1. discover the acceptance gate for what you are editing (synchronous)
list_tests { "filter":"jumper_lua" }
//   → { "tests":["player_jumper_lua_selfcheck_next"], "count":"1", "buildDir":"..." }

// 2. after editing, run it. run_tests is ASYNC and (by default) builds the tree
//    first; it returns a jobId to poll.
run_tests { "filter":"player_jumper_lua_selfcheck" }   // authed
//   → { "accepted":"1", "jobId":"a1b2c3...", "build":"1", "buildDir":"..." }

// 3. poll for the verdict
get_test_results { "jobId":"a1b2c3..." }
//   → { "jobId":"a1b2c3...", "status":"running" }     // keep polling
//   → { "status":"done", "total":"1", "passed":"0", "failed":"1",
//       "buildFailed":"0",
//       "failed_names":["player_jumper_lua_selfcheck_next"],
//       "failed_durations":["2.13"],
//       "failed_logtails":["...assertion failed: player never left the ground..."] }

// 4. the logtail is the "why" — read it, fix the script/code, rerun step 2.
//    A build that does not COMPILE short-circuits (no ctest runs):
//   → { "status":"done", "buildFailed":"1",
//       "buildErrors":"player.lua:12: ... error: ..." }
//    Fix the compile error first, then rerun.

// 5. iterate until green
get_test_results { "jobId":"<new job>" }
//   → { "status":"done", "total":"1", "passed":"1", "failed":"0",
//       "failed_names":[], "failed_logtails":[] }        // green

Notes that keep the loop honest:

  • device-labelled tests (simulator/emulator) are ALWAYS excluded from both

    list_tests and run_tests — a run never boots a device.

  • Pass build:"0" to run_tests to test an already-built tree as-is (fast) when

    you only changed a project script, not engine C++.

  • targets scopes the incremental build (e.g. just orkige_editor_tests);

    preset selects a different tree ("desktop-classic", a build/ dir name or an absolute path) — default is the editor's own build.

---

3. Debug a live game

Drive and inspect the RUNNING game: watch its live hierarchy, pause and step, poke a property or a cvar, capture a frame as proof, resume, stop. The runtime_* tools serve the LIVE player; list_hierarchy/get_component always read the EDIT world even during Play, so the two never blur together.

// 1. play a specific scene on the desktop target (async; poll get_state)
play { "scene":"scenes/level1.oscene", "target":"desktop" }   // authed
//   → { "accepted":"1", "play_mode":"launching", "target":"desktop" }
get_state {}
//   → { "play_mode":"playing", "remote_connected":"1", "remote_object_count":"12", ... }

// 2. inspect the live tree, then stream one object's component state
runtime_hierarchy {}                        // → { "ids":["Player","Ground",...], "selected":"", ... }
runtime_select { "id":"Player" }            // authed → { "selected":"Player" }
runtime_state {}                            // poll until ready="1":
//   → { "object":"Player", "ready":"1",
//       "properties":["TransformComponent.position","RigidBodyComponent.mass",...],
//       "values":["0 1 0","1",...], "kinds":["vec3","float",...], "readonly":["0","0",...] }

// 3. freeze time to examine a frame, advance exactly one physics tick
pause {}                                     // authed → {}   (get_state → play_mode:"paused")
step {}                                      // authed → {}   (one fixed tick while paused)

// 4. poke the running game live (NOT undoable — this is the live player, not the
//    edit world). A bad name/value comes back as a [remote] line, not an error.
set_runtime_property {
    "id":"Player", "component":"TransformComponent",
    "property":"position", "value":"2 3 4" }  // authed → {}
set_cvar { "name":"player_speed", "value":"8" } // authed → {}
runtime_state {}                             // read back → values reflect the writes
console_tail { "count":30 }                  // check for any [remote] rejection lines

// 5. capture the running frame as proof. screenshot_game is ASYNC: it returns a
//    baseline sequence; poll get_state until screenshot_seq exceeds it.
screenshot_game { "path":"/tmp/level1.png" }  // authed
//   → { "accepted":"1", "path":"/tmp/level1.png", "prev_screenshot_seq":"0" }
get_state {}
//   → poll until { "screenshot_seq":"1", "screenshot_ok":"1", "screenshot_path":"/tmp/level1.png" }

// 5b. for behaviour that unfolds over time, record a TRACE - a .jsonl flight
//     recorder you READ back (agents can't watch video). record_trace is ASYNC
//     the same way (poll record_seq), auto-stops at the time budget (or
//     stop_recording), and samples the world every everyNth frame.
resume {}                                    // authed → let it move while we record
record_trace { "path":"/tmp/level1.jsonl", "seconds":3, "everyNth":2 }  // authed
//   → { "accepted":"1", "path":"/tmp/level1.jsonl", "prev_record_seq":"0" }
get_state {}
//   → poll until { "record_seq":"1", "record_ok":"1", "record_path":"/tmp/level1.jsonl" }
// then READ the file and ASSERT on it. Each sample line looks like:
//   {"t":0.28,"frame":34,"dt":0.0166,"objects":[
//      {"id":"Player","name":"Player","pos":[3.5,1.2,0],"vel":[2,4.1,0],"active":1,"visible":1}]}
// e.g. an agent proving a jump: read every sample's Player pos[1] (y) and assert
// the series rises above its start then falls back - the arc is in the numbers.
// Event lines interleave, e.g. {"t":0.5,"frame":60,"event":"contactBegin","a":"Player","b":"Ground"}.

// 5c. check the HUD respects the device safe area (notch / home indicator).
//     get_safe_area reports the window size + insets; get_ui_layout the widget
//     rects. Assert every VISIBLE widget lies inside the safe box.
get_safe_area {}
//   → { "window_w":"1179", "window_h":"2556", "safe_top":"120", "safe_left":"0", ... }
get_ui_layout {}
//   → { "ids":["hud.mode","hud.wins"], "rects":["16 120 180 28 1","959 120 200 28 1"] }
// for each rect "l t w h v" with v=1: assert l>=safe_left, t>=safe_top,
// l+w<=window_w-safe_right, t+h<=window_h-safe_bottom.

// 6. let it run again, then stop back to edit mode
resume {}                                    // authed → {}
stop {}                                      // authed → {}

Every runtime_* verb (and pause/resume/step/screenshot_game/ record_trace/stop_recording) returns isError with "no live player - start Play first" when nothing is playing, so the edit-world / live-game boundary is never ambiguous. screenshot_game and record_trace are desktop-play only (the path lives on the player's filesystem, which the editor shares only on desktop). Prefer record_trace when the evidence is motion or timing over a window — a jump arc, a tween, a physics settle, a contact — and read the numbers back. When you only need to confirm what's on screen right now, screenshot_game gives the frame; and the zero-cost DETERMINISTIC alternative is a pause/step/screenshot_game loop (step advances exactly one frame), which needs no real-time capture at all.


3a. Playtest it: drive the game and assert it responded

Reading a running game is only half of a playtest — the other half is PLAYING it. send_input replays a whole input gesture through the real input path, so the game cannot tell it from hardware. Because the gesture is applied one step per frame at the runtime's frame boundary and confirmed only after every injected frame has been STEPPED, the loop is deterministic: no sleeping, no polling for a guess about when the input "probably" landed.

// the game under test: scripts/player.lua moves the object while the "move"
// action is held and counts a jump per "jump" press (the default action map
// binds move to WASD/arrows and jump to SPACE - no config file needed).
play { "target":"desktop" }                  // authed, async
get_state {}                                 // poll → { "play_mode":"playing", "remote_connected":"1" }

// 1. baseline: stream the object whose reaction you will assert on
runtime_select { "id":"Player" }             // authed → { "selected":"Player" }
runtime_state {}                             // poll until ready="1"
//   → { "properties":["TransformComponent.position",...], "values":["0 1 0",...] }
//   remember x = 0

// 2. PLAY IT: hold RIGHT for 12 frames. One call, one gesture.
send_input { "steps":["key press RIGHT 12"] }              // authed
//   → { "accepted":"1", "prev_input_seq":"0", "input_frames":"13", "input_events":"2" }

// 3. wait for the CONFIRMATION - not a sleep. When input_seq advances with
//    input_ok="1", all 13 frames have been stepped and the result is readable.
get_state {}
//   → poll until { "input_seq":"1", "input_ok":"1", "input_frames":"13",
//                  "input_running":"0", "input_message":"" }

// 4. ASSERT THE GAME RESPONDED - the real gameplay claim
runtime_state {}
//   → values now carry "3 1 0": 12 held frames x the script's per-tick step.
//     Frame-exact, so you can assert the NUMBER, not just "it moved".

// 5. a tap, and a compound gesture. Steps run in written order:
send_input { "steps":[
    "key press SPACE",          // one-frame tap
    "wait 3",                   // three frames of nothing
    "key press SPACE",          // a second, DISTINCT press
    "pointer click 640 360" ] }                            // authed
get_state {}                                 // poll input_seq again
runtime_state {}                             // → the jump counter reads 2, not 1

// 6. does it LOOK right? and did anything complain?
screenshot_game { "path":"/tmp/after_input.png" }          // authed, poll screenshot_seq
console_tail { "count":30 }                  // any [remote] error the input triggered

stop {}                                      // authed

What the grammar gives you (full table in Docs/mcp.md): key down|up <NAME> for an edge you hold across several gestures, key press <NAME> [frames] for a timed hold, pointer move|down|up|click <x> <y> [button] in window pixels (pair with get_safe_area for the size and get_ui_layout for widget rects), tilt angle <radians> / tilt vector <x> <y> for a tilt-controlled game, and wait <frames> to space things out.

Keeping the loop honest:

  • The confirmation IS the synchronisation point. Assert right after it; the

    runtime_state stream is ~15 Hz, so poll the readback until it settles rather than reading once.

  • A malformed step is refused up front naming its 1-based index

    (step 2 (unknown key name 'JUMP')) — the editor compiles the same grammar the runtime does, so a typo never reaches the game as a silent no-op.

  • ONE gesture at a time, and send_input is refused while the session is PAUSED

    (a frozen world never ticks the scripts that read the input). A gesture already in flight when you pause simply HOLDS and continues on resume — and a debug step advances it by exactly one frame, which is the deterministic way to inspect a gesture mid-flight.

  • For a WIDGET, prefer gui_press(id) — it finds the widget's centre itself and

    respects modal/disabled semantics. Use send_input's pointer steps for in-world clicks, drags and taps that are not a widget.

  • On a phone with a real accelerometer a tilt step is overruled by the sensor:

    the gesture still succeeds and input_message says so. Read it.

---

3b. Profile a live game (where does the frame go? what allocates?)

The perf instruments answer both questions from readback alone: the CPU frame profile (get_profile) breaks the frame into the canonical tick phases with a full scope hierarchy below them, and the allocation counters (get_state) count tracked allocation events at the engine's own seams per subsystem tag. The loop is: profile → find the hot subsystem → fix → re-profile.

// 1. play, then read the frame breakdown. A Debug player streams the profile
//    unprompted; on a Release player the FIRST get_profile arms the profiler,
//    so poll until profile_seq advances (~4 snapshots/second).
play { "scene":"scenes/level1.oscene" }       // authed
get_profile {}
//   → { "names":["input","scripts","objects.update","events","events.tick",
//                "tweens","physics","physics.update","load","audio","present",
//                "debug","render"],
//       "info":["0 1 0.008 0.02","0 1 0.755 1.1","1 1 0.71 1.0", ...],
//       "frame_ms":"8.001", "profile_seq":"12" }
// each info entry is "depth calls milliseconds maxMilliseconds"; depth-0 rows
// are the canonical tick phases, deeper rows the engine scopes inside them.
// Here: an 8 ms frame - 0.755 ms scripts, 6.7 ms render, the rest is cheap.

// 2. the allocation side rides get_state (streamed on the same cadence):
get_state {}
//   → { ..., "frame_ms":"8.001", "alloc_per_frame":"0", "alloc_peak":"4",
//       "alloc_tags":["events","gui","physics","tweens","particles","other"],
//       "alloc_counts":["0","0","0","0","0","0"], "profile_seq":"13" }
// the unit is TRACKED ALLOCATION EVENTS at the engine's own seams (event
// queue nodes, container growth in gui/physics/tween/particle hot paths) -
// per-subsystem engine churn, NOT libc totals (Lua's VM churn is invisible
// here by design; mem_rss is the process-level number). A steady scene reads
// 0; a nonzero tag names the subsystem to look at.

// 3. fix the hot thing (edit the script/scene through the normal authoring
//    verbs, reload_script or set_cvar to try it live), then RE-PROFILE:
reload_script {}                              // authed - hot-swap the Lua
get_profile {}                                // poll until profile_seq advanced
//   → compare the same phase rows before/after; the numbers are the proof.

// 4. for a spike that unfolds over time, record_trace: every sample line now
//    carries "alloc" (that frame's tracked-allocation count) and "phases"
//    (the depth-0 phase times), so a hitch is findable offline:
record_trace { "path":"/tmp/perf.jsonl", "seconds":3 }  // authed
//   sample lines: {"t":0.5,"frame":60,"dt":0.016,"mem":214958080,"alloc":2,
//                  "phases":{"scripts":0.7,"physics":0.3,"render":6.7},...}
// read the file, find the frame where dt spiked, and the phases object on
// that line says which phase paid for it.

Honesty notes: scope timings measure the INSTRUMENTED code (OPROFILE sites - the tick phases plus the engine's own scopes); the profiler itself costs ~70 ns per scope when on, ~8 ns when off (Release default off, which is why the first Release get_profile returns nothing yet). The editor's own frame is not profiled - these instruments serve the running GAME.


3c. Breakpoint-debug a script (pause mid-statement, inspect, step)

When log lines and property readback are not enough — "WHY is this variable wrong at this line?" — set a breakpoint and walk the code. A hit pauses the player MID-STATEMENT (distinct from the frame-boundary pause verb), keeps the session serviced, and exposes the call stack + live locals. Break-hit is asynchronous: get_debug_state is the poll. Design + panel counterpart: script-debugging.md.

When you don't even know WHERE the scripts run — "where does code even run right now?" — debug_break_next is the orientation move: with a game already playing (no breakpoint needed), it pauses into the first script line executed next, landing on get_debug_state exactly like a breakpoint hit, so you can read the stack + locals and step from wherever that turns out to be.

// 0. (orientation) no breakpoint, game already playing: pause into the next
//    script line to see where code runs. Async like a hit - poll break_seq.
debug_break_next {}                                         // authed
//   → { "accepted":"1", "prev_break_seq":"0" }             // then poll:
get_debug_state {}
//   → { "paused_at_breakpoint":"1", "file":"scripts/player.lua", "line":"42",
//       "break_seq":"1", "stack_sources":[...], ... }      // step/continue from here

// 1. set the breakpoint BEFORE (or during) play - the set is per-project,
//    persisted, and pushed to the player automatically on connect/change.
set_breakpoint { "file":"scripts/player.lua", "line":42 }   // authed
//   → { "breakpoints":["scripts/player.lua:42"] }
list_breakpoints {}                                         // confirm anytime

// 2. play, then poll for the hit (the update loop reaches line 42 within a
//    frame once the script ticks):
play { "target":"desktop" }                                 // authed
get_debug_state {}
//   → { "paused_at_breakpoint":"0", ... }                  // not yet - poll
get_debug_state {}
//   → { "paused_at_breakpoint":"1", "file":"scripts/player.lua", "line":"42",
//       "break_seq":"1",
//       "stack_sources":["scripts/player.lua","[host]"],
//       "stack_lines":["42","-1"], "stack_functions":["update",""] }

// 3. inspect the paused frame (frame 0 = innermost). ASYNC like the hit
//    itself: a first call may answer pending, call again.
get_locals { "frame":0 }
//   → { "pending":"1" }                                    // once more:
get_locals { "frame":0 }
//   → { "pending":"0",
//       "names":["self","dt","grounded"], "scopes":["local","local","local"],
//       "types":["table","number","boolean"],
//       "values":["table","0.0166667","false"], "expandable":["1","0","0"] }
// expand ONE table explicitly (bounded - never a whole-state dump); the
// chain walks table keys, "[3]" bracket form for non-string keys:
get_locals { "frame":0, "expand":["self"] }
//   → rows with scope "field": id, transform, rigidbody, ...

// 4. step. Each release is accepted immediately; the landing raises a NEW
//    break - poll until break_seq advances past prev_break_seq:
debug_step_over {}                                          // authed
//   → { "accepted":"1", "prev_break_seq":"1" }
get_debug_state {}
//   → { "paused_at_breakpoint":"1", "line":"43", "break_seq":"2", ... }
// debug_step_in dives into the call on the current line; debug_step_out
// runs until the current function returns.

// 5. resume. With the breakpoint still set the NEXT frame's update hits it
//    again (poll break_seq); when done, clear and run free:
debug_continue {}                                           // authed
clear_breakpoint { "all":"1" }                              // authed
debug_continue {}
get_debug_state {}
//   → { "paused_at_breakpoint":"0", "play_mode":"playing", ... }

Honesty notes: while broken, the frame never finishes — the streamed hierarchy/state/stats freeze until the resume (only the debug link stays serviced; any other verb you send mid-break is DEFERRED to the next frame boundary, so a set_runtime_property lands after the resume, never inside script execution). A vanished client auto-resumes the game — a breakpoint can never wedge a player you lost. The browser play target refuses set_breakpoint honestly (a page cannot block its main thread); scripting- disabled players refuse too. Variables are READ-only in v1 (no eval at a frame) — live tuning stays set_runtime_property/set_cvar.


4. Author levels

Build a tile-based level by stamping prefabs onto a snap grid, then wire the finished scene into the game's level sequence and play it — the same grid-paint loop the editor's paint tool drives, done over MCP. Assumes a project is open and a scene is loaded (the grid coincides with the scene's slots when it carries a level, otherwise it is the translate snap step at the world origin).

// 1. see the tile palette and the grid the paint verbs snap to
list_paint_prefabs {}
//   → { "paths":["assets/Wall.oprefab","assets/Floor.oprefab"],
//       "names":["Wall","Floor"], "count":"2",
//       "origin_x":"0", "origin_y":"0", "cell_size":"0.5" }

// 2. stamp a few cells. Give a cell by { col, row }, or a world "position" that
//    snaps to the nearest cell — either way the reply reports the SNAPPED cell.
//    Painting the same cell replaces its occupant; the identical tile again is a
//    no-op (painted:"0"), so a sweep never churns the undo stack.
paint_prefab { "prefab":"assets/Floor.oprefab", "cell": { "col":0, "row":0 } }  // authed
//   → { "id":"Floor", "painted":"1", "col":"0", "row":"0", "x":"0", "y":"0" }
paint_prefab { "prefab":"assets/Floor.oprefab", "cell": { "col":1, "row":0 } }  // authed
//   → { "id":"Floor 2", "painted":"1", "col":"1", "row":"0", "x":"0.5", "y":"0" }
paint_prefab { "prefab":"assets/Wall.oprefab", "position":"0 0.5" }             // authed
//   → { "id":"Wall", "painted":"1", "col":"0", "row":"1", "x":"0", "y":"0.5" }

// 3. evidence the tiles landed (the painted roots appear in the EDIT hierarchy)
list_hierarchy {}                           // → { "ids":[...,"Floor","Floor 2","Wall"], ... }

// 4. drop a wrong tile? erase that cell (undoable). undo/redo work too.
erase_cell { "cell": { "col":0, "row":1 } }  // authed → { "erased":"1", "col":"0", "row":"1", ... }
undo {}                                      // authed → the erased tile comes back

// 5. save, then append the scene to the project's level sequence (levels.olevels;
//    mints the manifest "levels" setting the first time). NOT undoable, and it
//    needs a SAVED scene inside the project — save_scene first.
save_scene { "scene":"scenes/level2.oscene" }   // authed → { "scene_path":".../level2.oscene" }
add_scene_to_levels {}                          // authed → { "scene_path":".../level2.oscene" }

// 6. play the level for proof (async; poll get_state), or run its selfcheck
play { "scene":"scenes/level2.oscene", "target":"desktop" }   // authed
get_state {}                                //   → poll until play_mode:"playing"

add_scene_to_levels refuses (isError) when no project is open, the scene is unsaved or outside the project root, or the scene is already in the sequence — read the reply back rather than assuming the append took.

4b. Edit a prefab asset in isolation

Change a .oprefab ONCE and every instance across every scene picks the edit up (instances re-instantiate from the file at their next load, per-instance overrides re-applied). open_prefab swaps the live scene aside into a temp snapshot and loads the prefab subtree into the ONE edit world, so every ordinary editing verb works on the prefab UNCHANGED — this is the single-world payoff, not a second set of verbs. While staged, the scene/project lifecycle, play, add_scene_to_levels and the paint/instance verbs are refused with a clear prefab-mode error (read the reply back — you get an honest message, not a silent no-op).

// 1. open the prefab (by project path, or by stable "asset" id). The stage root
//    is the file stem; get_state now reports the prefab context.
open_prefab { "path":"assets/Enemy.oprefab" }   // authed → { "root_id":"Enemy", "prefab_path":".../Enemy.oprefab" }
get_state {}                                     //   → { "edit_context":"prefab", "prefab_root":"Enemy",
                                                 //       "prefab_path":".../Enemy.oprefab", "prefab_dirty":"0", ... }

// 2. the world is the prefab subtree only — edit it with the NORMAL verbs.
list_hierarchy {}                               // → the prefab's objects (the scene is swapped out)
set_component { "id":"Enemy/Body", "component":"SpriteComponent",
                "properties": { "zOrder":"3" } }   // authed — edits a child of the prefab

// 3. a scene/project verb is refused while staged (honest prefab-mode error):
save_scene {}                                   // authed → isError: "save_scene is unavailable while a prefab is open ..."

// 4. write the asset, then pop back to the scene. close_prefab REQUIRES a policy:
//    "save" writes first (a refused save cancels the close), "discard" drops
//    unsaved stage edits. A policy-less close is refused.
save_prefab {}                                  // authed → { "prefab_path":".../Enemy.oprefab" }
close_prefab { "policy":"discard" }             // authed → { "scene_path":".../level.oscene" }
get_state {}                                    //   → { "edit_context":"scene", ... } (the scene is back;
                                                 //       its Enemy instances refreshed from the edited file)

Entering prefab mode drops the current scene's undo history (per-context scope, like opening another scene); save_prefab refuses (isError) when the stage root was deleted or objects exist OUTSIDE the single root (parent them under the root first). The scene_path/scene_dirty in get_state keep reporting the STASHED scene while staged, so your model of the document you will return to stays truthful.

5. Design a UI screen (the collaborative preview loop)

Author a .oui screen, see it rendered at real device sizes WITHOUT booting the game, and iterate — while a human watching the editor's GUI Preview tab sees every edit live (the tab shares the same offscreen stage and reloads the file on change). preview_ui renders through the real gui stack into an offscreen target, so it needs no running player; it is Ogre-Next only (the classic editor errors honestly). Assumes a project carrying a gui_default atlas is open.

// 1. author a screen as plain text (jailed to the project root)
write_project_file {                                                 // authed
  "path":"screens/title.oui",
  "content":"[Layout]\natlas = gui_default\n\n[Button play]\nz = 2\nsprite = button\nfont = 9\ntext = Play\nposition = 40 40\nsize = 300 96\n"
}
//   → { "path":"screens/title.oui", "bytes":"..." }

// 2. preview it at a phone context; get a screenshot + the resolved rects
preview_ui {                                                         // authed
  "file":"screens/title.oui",
  "width":1179, "height":2556, "scale":3, "insets":"0 141 0 102"
}
//   → { "path":"/tmp/orkige_preview_ui.png", "width":"1179", "height":"2556",
//       "batch_count":"1",
//       "ids":["play"], "rects":["40 40 300 96 1 1 0"] }
//   the render comes back INLINE as an image content block - you SEE it in the
//   tool result directly (no read-back hop); the path is still there for pixel
//   diffing. read the rects to CHECK the layout ("left top w h visible enabled
//   modal"). inline:false (or a PNG over 4 MiB) returns path-only.

// 3. a device-matrix sweep in ONE call (phone + tablet) - one png per context
preview_ui {                                                         // authed
  "file":"screens/title.oui",
  "contexts":"1179x2556@3/0,141,0,102; 2048x1536@2"
}
//   → { "paths":["/tmp/orkige_preview_ui_0.png","/tmp/orkige_preview_ui_1.png"],
//       "context_labels":["1179x2556@3","2048x1536@2"],
//       "ids":["play"], "rects":[...] }   // rects are the FIRST context

// 4. the button sits too high on the tablet? edit the file and preview again -
//    the human's GUI Preview tab updates live (it watches the file's mtime)
write_project_file { "path":"screens/title.oui", "content":"...position = 40 200..." }  // authed
preview_ui { "file":"screens/title.oui", "width":2048, "height":1536, "scale":2 }       // authed
//   → the returned rect for "play" moved - proof the edit took, no player booted

// 5. preview a LOCALISED screen (its @key captions) in a target language, no
//    play session: the result lists the loaded languages and echoes the applied
//    one (a project with no loc/ directory ignores 'language' with a note)
preview_ui { "file":"screens/title.oui", "language":"de" }                               // authed
//   → { ..., "language":"de", "languages":["de","en","en-XA"] }
//   read the png back to SEE the German text; omit 'language' for the source.

Evidence, not assumption: preview_ui returns batch_count (> 0 means the gui actually submitted geometry) and the resolved rects; compare the rects across edits/contexts to confirm the layout holds. A missing file returns isError (an honest error, not a silent success). The whole loop is exercised end to end by the editor_control self-test (write → preview → edit → preview → assert the rect moved → a missing file errors).

The animation twin is preview_animation { "asset":"assets/hero.oanim", "clip":"walk", "time":0.4, "blendClip":"run", "blendWeight":0.3 } (authed): it renders a .oanim pose (import a Lottie .json with import_asset to cook one, or write_project_file the text directly) at a chosen clip/time — optionally blending a second same-rig clip — to a PNG plus a readback (clips, frame, duration, layer_count, vertex_count, visible_pixel_count, coloured_pixel_count), so an agent scrubs a cycle (t=0 / mid / end differ), rejects blank/all-white output and validates a blend without a play session.

screenshot, preview_ui and preview_animation inline the captured PNG as an MCP image content block alongside the text/structuredContent — you see the render in the tool result itself, no read-back hop. For a preview_ui sweep only the FIRST context's image is inlined (to bound payload; the row notes it); the other paths are on disk. Pass inline:false (or exceed the 4 MiB cap — noted as "inline_skipped":"too_large") to get path-only. screenshot_game is async (the player writes the file after the accepted reply), so it has no image at reply time and stays path-only — poll get_state for the confirmation, then read the path off the shared filesystem.

6. Leave the human a reusable tool (editor scripts)

Some chores repeat: reframe a level, re-tag a batch of tiles, generate a grid. Instead of doing them by hand every time, author them ONCE as an EDITOR TOOL — a scripts/<name>.editor.lua file. It runs in the editor (not the game) through the SAME editor.* surface you already drive here (each function mirrors an MCP verb), its whole run folds into ONE undo step, and afterwards a HUMAN runs it from the editor's Tools menu forever after — or you re-trigger it with run_editor_script. This is the durable half of the collaboration: the agent leaves behind a button, not just an edit.

// 1. author the tool (a plain project file - the jail + LF rules apply)
write_project_file { "path":"scripts/frame_level.editor.lua",
  "content":"-- tool: Frame The Level\nlocal function findLevel()\n  for _,id in ipairs(editor.list_hierarchy().ids or {}) do\n    for _,c in ipairs(editor.get_object{id=id}.components or {}) do\n      if c=='LevelComponent' then return id end\n    end\n  end\nend\nlocal id=findLevel(); if not id then editor.log('no level'); return end\nlocal l=editor.get_component{id=id,component='LevelComponent'}\nlocal cols,rows=math.floor(tonumber(l.cols)),math.floor(tonumber(l.rows))\nfor c=0,cols-1 do for r=0,rows-1 do\n  if c==0 or c==cols-1 or r==0 or r==rows-1 then\n    editor.paint_asset{asset='assets/wall.png',cell={col=c,row=r}}\n  end\nend end\n" }
//   → { "path":"scripts/frame_level.editor.lua", "bytes":"..." }  // it now shows in the Tools menu

// 2. run it (authed) - the edits fold into ONE undo step
run_editor_script { "name":"frame_level" }
//   → { "name":"frame_level", "command_count":"8" }   // 8 wall tiles, one Cmd+Z reverts all

// 3. a BROKEN tool fails honestly and leaves NO partial edits
run_editor_script { "name":"frame_level" }
//   → isError: "editor tool 'frame_level' failed: .../frame_level.editor.lua:6: attempt to ..."
//     (nothing half-applied; fix the file with write_project_file and re-run)

The editor.* verbs a tool can call, and the one-undo / rollback / noscript contract, are documented in Docs/lua-api.md (Editor scripts). The projects/roller/scripts/border_walls.editor.lua sample is the living example; the editor_scripts selfcheck and the editor_control run_editor_script leg exercise this loop end to end.