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:
{ "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):
onegame run --headless -- --live /tmp/games/orchard.socklistening on /tmp/games/orchard.sock (MCP, newline-delimited JSON-RPC); connect with: onegame mcp --socket /tmp/games/orchard.sockA small MCP client in Python, for the apple tree from Getting started:
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")
python3 mcp_probe.py --socket /tmp/games/orchard.sock47 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:
| Area | Tools |
|---|---|
| Read | info, hash, state_query, commands_list, ui_tree, ui_query, ui_hit, net_status |
| Change state | command_send, state_patch, step, reset, fixture_load, snapshot_load, screen_open |
| Input | ui_click, ui_tap, ui_invoke, input_pointer, input_key, input_text, input_composition, input_nav, input_touch, clipboard_get, clipboard_set |
| Output | capture_frame, capture_sequence, snapshot_save, audio_capture |
| Checks | theme_lint, audio, audio_events, shaders, render_graph |
| Network | net_wait, net_diverge (corrupts a replica on purpose, to prove drift detection) |
| 3D | camera, pick, project |
| Platform | ui_scale, quality, app_lifecycle, platform_native, store, steam_input |
| Session | quit |
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
--liveneeds a Debug build: a Release build exits 5 withno_dev_tools.- A windowed game can be driven the same way while it presents (
onegame run -- --live <socket> --realtime).capture_framewith"presented": truereturns the frame the window shows. - Each node of a cluster has its own socket.
onegame cluster call <node> <tool> <json>runs one tool, andonegame mcp --cluster .onegame-clusterserves every node, with anodeargument 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.