Recursive AI

Drive a running game over MCP

All onegame recipes

Control a running Debug build of a C# game from an MCP client or a script: read state, click by id, step time, send commands, load fixtures and capture frames over the onegame live-control protocol.

Debug builds of a game listen for live control on a local socket. onegame mcp bridges that socket to an MCP server on stdio, so any MCP client, or a script that speaks JSON-RPC, can drive the game with the same operations scenarios use. Release builds and exports contain none of this.

From an MCP client

Point the client at the bridge. With --project it builds the game, starts it in a window and bridges to it:

JSON
{ "mcpServers": { "orchard": { "command": "onegame", "args": ["mcp", "--project", "."] } } }

--socket <path> attaches to a game that is already running instead. With neither, the bridge attaches to the only live game.

From a script

Start the game headless with a live socket. It waits for step instead of running on the clock (add --realtime to let it run):

Terminal
onegame run --headless -- --live /tmp/games/orchard.sock
Text
listening on /tmp/games/orchard.sock (MCP, newline-delimited JSON-RPC); connect with: onegame mcp --socket /tmp/games/orchard.sock

A small MCP client in Python, for the apple tree from Getting started:

PYTHON
import json, subprocess, sys
 
# Start the MCP bridge with this script's arguments (here --socket <path>); it speaks JSON-RPC on stdio.
bridge = subprocess.Popen(["onegame", "mcp", *sys.argv[1:]], stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True)
next_id = 0
 
def rpc(method, params=None):
    global next_id
    next_id += 1
    bridge.stdin.write(json.dumps({"jsonrpc": "2.0", "id": next_id, "method": method, "params": params or {}}) + "\n")
    bridge.stdin.flush()
    return json.loads(bridge.stdout.readline())
 
def call(name, args=None):
    result = rpc("tools/call", {"name": name, "arguments": args or {}})
    print(f"{name} {json.dumps(args or {})} -> {result['result']['content'][0]['text'][:200]}")
 
rpc("initialize", {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "probe", "version": "1"}})
bridge.stdin.write(json.dumps({"jsonrpc": "2.0", "method": "notifications/initialized"}) + "\n")
bridge.stdin.flush()
tools = rpc("tools/list")["result"]["tools"]
print(len(tools), "tools")
 
call("info")
call("step", {"frames": 300})
call("state_query", {"entity": "Tree"})
call("ui_click", {"id": "pick"})
call("step", {"frames": 1})
call("state_query", {"entity": "Tree"})
call("capture_frame", {"path": "shots/live.png", "size": "360p"})
call("quit")
Terminal
python3 mcp_probe.py --socket /tmp/games/orchard.sock
Text
47 tools
info {} -> {"game":"orchard","tick":0,"frame":0,"hash":"6554bfe7b94da5ca","width":1280,"height":720,"scale":1,"headless":true,"screen":"main","entities":1,"screens":["main"],"fixtures":["heavy-tree","bare-tree"]
step {"frames": 300} -> {"frame":300,"tick":300,"hash":"d0045829889cf126"}
state_query {"entity": "Tree"} -> {"id":1,"type":"Tree","Apples":13,"Picked":0,"Growth":0}
ui_click {"id": "pick"} -> {"focused":"Button#pick","hovered":"Label","tick":300}
step {"frames": 1} -> {"frame":301,"tick":301,"hash":"80894b787970351f"}
state_query {"entity": "Tree"} -> {"id":1,"type":"Tree","Apples":12,"Picked":1,"Growth":1}
capture_frame {"path": "shots/live.png", "size": "360p"} -> {"path":"/tmp/games/orchard/artifacts/shots/live.png","width":640,"height":360,"scale":1,"sha256":"c944c46957490070d825687d2346bb511c55d0ae15387a4938a5bd100dc03e16","renderer":"NVIDIA GeForce RTX 4070
quit {} -> {"frame":301,"tick":301,"hash":"80894b787970351f"}

The script cuts each result at 200 characters. The hash after 300 steps, d0045829889cf126, is the one onegame run --headless --frames 300 --json prints: a live session is the same deterministic game.

The tools

The 47 tools at 1.1.2:

AreaTools
Readinfo, hash, state_query, commands_list, ui_tree, ui_query, ui_hit, net_status
Change statecommand_send, state_patch, step, reset, fixture_load, snapshot_load, screen_open
Inputui_click, ui_tap, ui_invoke, input_pointer, input_key, input_text, input_composition, input_nav, input_touch, clipboard_get, clipboard_set
Outputcapture_frame, capture_sequence, snapshot_save, audio_capture
Checkstheme_lint, audio, audio_events, shaders, render_graph
Networknet_wait, net_diverge (corrupts a replica on purpose, to prove drift detection)
3Dcamera, pick, project
Platformui_scale, quality, app_lifecycle, platform_native, store, steam_input
Sessionquit

Errors come back as {"error":{"code","message"}} with a stable code such as bad_args, not_found or path_outside. File paths are relative to the game’s output folder (artifacts/ by default).

Good to know

  • --live needs a Debug build: a Release build exits 5 with no_dev_tools.
  • A windowed game can be driven the same way while it presents (onegame run -- --live <socket> --realtime). capture_frame with "presented": true returns the frame the window shows.
  • Each node of a cluster has its own socket. onegame cluster call <node> <tool> <json> runs one tool, and onegame mcp --cluster .onegame-cluster serves every node, with a node argument on each tool. See Client/server sync with a dedicated server.
  • The socket is local. Live control over the network (--live tcp:*:PORT) is for devices on a network you trust.

More in Driving a game, sections 5 and 6.