The game development control channel¶
A loopback socket into a running client, so it can be driven and questioned while it runs instead of being relaunched for every question. It is a development aid, not a feature, and it is off unless you switch it on.
This is Goanna's privileged game development interface. It is intentionally
separate from the constrained player agent interface described in
docs/agents/agent-interfaces.md. Commands such as eval, run, teleport and
arbitrary method calls must never be exposed through the player interface.
Why¶
Before this, every question cost a process launch. You set one of the hundred
odd GOANNA_* variables listed in docs/develop/building.md, connected, waited
a fixed number of seconds, saved a PNG and quit. Three things follow from that
and all three are bad.
The loop is slow. A shader tweak, a lighting value or a material strength is seconds of thought and half a minute of relaunch, so few get tried.
The timing is a guess. tools/dev/shotcheck.py opens by listing four
conclusions drawn from frames captured before the world had arrived: an
inverted normal map, a jungle canopy measured as snow, an autojump verified
against a swimming character, and rain checked against a clear sky. A wait
on a wall clock cannot tell you the blocks are there. A wait on the block
count can.
The answer is a picture. Reading a number off a screenshot is guesswork
where a query would be exact, and client.node_name_at() and its ninety odd
siblings were already there, with no way to reach them from outside.
Starting it¶
Test clients run in headless gamescope, where the person at the machine never sees them:
tools/goanna-headless start --control-port 30851 --server 127.0.0.1:30000 \
--name dev --size 1280x720
That runs Goanna from the checkout the tool is in (--project picks another
checkout or a worktree) inside gamescope's headless backend: a compositor
with its own nested X display and no output at all. The client renders on
the GPU as usual, but no window reaches the desktop, nothing takes the focus
and nothing grabs the mouse. The command returns once the control channel
answers, with an instance id (goanna-30851), the PIDs and the log. It
refuses a control port that is already in use; leave --control-port out
and a free one from 30801 up is chosen, leaving 30800, which every tool
talks to when not told a port, to clients started by hand.
tools/goanna-headless list shows every instance on the machine, stop
goanna-30851 stops one, and shot goanna-30851 /tmp/a.png saves the
virtual display as gamescope composites it. The MCP server below does all
of this as tools. The rules that go with it, and the difference between
headless gamescope and Godot's own --headless, are in
docs/agents/agent-interfaces.md, "Rules for test clients".
--software renders on lavapipe and llvmpipe and never opens a GPU context.
Goanna then draws about one frame a second at 1280 by 720, which is enough
for forms and state and too slow for anything visual. It exists because the
GPU on the machine this was written on went into a state where it refused
every new context (see Limits).
The launcher sets the variables itself. Starting Godot by hand still works:
GOANNA_CONTROL=1 listens on 127.0.0.1:30800. Any other number is used as
the port. It binds to the loopback address only, so nothing off this machine
can reach it. With the channel open the client is in test mode: it never
captures the OS pointer and asks for no focus, because a control session is
usually unattended, and a client on the desktop that grabbed the mouse took
it away from the owner in the middle of a video call. Capture is kept inside
the client instead, so Escape and the forms behave as they do for a player,
and only mouse input pushed in through the channel counts as captured: a real
pointer crossing the window does not turn the camera. The camera is driven
with pose and look. GOANNA_NO_POINTER_CAPTURE=1 gives test mode without
the channel. A player's launch has neither and captures as always.
Then, from anywhere on the same machine:
tools/goanna-control status
tools/goanna-control tp 10 65 -20
tools/goanna-control time 0.5
tools/goanna-control set light_sun 2.0
tools/goanna-control shot /tmp/a.png
tools/goanna-control inspect target=materials
tools/goanna-control inspect target=materials texture=default_stone.png
tools/goanna-control inspect target=scene depth=2 limit=100
The port is not inferred at either end, and on a machine running more than
one client that is a hazard rather than a nuisance. tools/goanna-control
talks to 30800 unless you pass --port, and does not read GOANNA_CONTROL,
so a client started on another port is not found and a client that happens
to be on 30800 is. The client side has the mirror of it: whoever binds 30800
first holds it for everybody, so a second client launched without
GOANNA_CONTROL set silently has no channel of its own while the tool
cheerfully drives the first one.
This has already happened twice in one evening, in both directions, between
two people who each thought they were driving their own client: a settings
change and a teleport went into someone else's session each time. Give every
session its own port, set GOANNA_CONTROL explicitly when you launch, pass
--port on every call, and check label or status before you believe a
reply came from the client you meant.
The wire format is one JSON object per line over TCP,
{"id": 1, "cmd": "tp", "args": {"x": 10, "y": 65, "z": -20}}, and one
reply object per line, so anything that can open a socket can drive it.
tools/goanna-control is a convenience, not the interface. A line that does
not start with a brace is read as cmd key=value key=value, which makes the
channel usable from nc when something is wrong with the tooling.
One command runs at a time. Several of them wait on frames, and two overlapping camera moves would each photograph the other's pose.
Commands¶
help returns the current list. As of this writing:
| Command | What it does |
|---|---|
ping |
Liveness, and how long the client has been up. |
label <text> |
What this session is testing. Shown on screen; see below. |
status |
Session state, camera pose, block, entity and media counts. |
inspect target=<kind> |
Structured scene, render, material, sky, entity, inventory or node data. |
tp x y z |
Teleport through the server, then wait for the blocks to arrive. |
pose x y z pitch= yaw= |
Place the camera. No server move, so use it for viewpoints, not for travel. |
look x y z |
Aim the camera at a point. |
fly on |
Fly camera, or walk with collision. |
time <0..1> |
Override the time of day, for this client only. Below 0 hands the clock back to the server. server=true also runs /time, which moves it for everyone, and is what you want whenever the sky itself matters: see below. |
weather <kind> |
The game's own weather command. fake=true injects a local spawner instead. |
spawn <name> [x y z] |
/spawnentity through the server. |
give <item> count= |
/giveme through the server. |
chat <text> |
Send a chat line and return what the server said back. |
set <key> <value> |
Any settings panel key, applied live through the same code the panel uses. |
get <key>, settings |
One value, or every value with the value it had at startup. |
deviations |
What has been moved since startup. See below. |
shot <path> |
Settle, capture a PNG, write a JSON sidecar beside it. |
wait settle= / frames= / ms= / expr= |
Wait on a condition. |
reload_shader <res://path> |
Recompile a .gdshader from disk, in place, without a relaunch. |
call <method> args=[...] |
Any GoannaClient method. Three numbers become a Vector3. |
eval <expr> |
One GDScript expression, evaluated against main. |
run src=<snippet> |
A GDScript snippet, for anything with a loop or a wait in it. |
quit |
Disconnect and close the client. |
ui_tree |
The open form or menu: elements, slots, labels, rectangles and state. See below. |
ui_click, ui_hover, ui_scroll |
Click, rest the pointer on, or scroll a target, with events pushed inside the client. |
ui_type name= text= |
Click into a field and type, optionally clearing it first and pressing Enter after. |
key <name or action> |
Press, release or tap a key or a game action: inventory, menu, hotbar3, dig. |
Two clocks, and the one that will bite you¶
time moves this client's clock and nothing else. The server keeps its own,
and a game computes its sky from the server's: Mineclonia's night sky
colours, its weather and its day night ratio all come from there. So a
client side override moves the sun and leaves the sky it is meant to be
setting behind, and the two disagree silently.
Two sessions lost measurements to this in one evening. One noticed only because the frames came back too dark to use. Frames that are subtly wrong rather than obviously wrong are the case that will cost someone a day, and nothing in the reply says which clock you moved.
So: time for a quick look at the sun, and time <t> server=true, or a
plain /time over chat, for anything where the sky, the weather or the
ambient light is part of what you are measuring. If a night measurement
comes back brighter or darker than it should, check the server's clock
before you believe the renderer.
run is the one that matters most. eval handles a single expression, so
anything with a variable, a loop or a wait in it goes here instead. The
snippet becomes the body of a function with main, client, cam and ui
in scope, it may await, and whatever it returns comes back as JSON:
tools/goanna-control run src='var p = client.server_player_position()
var y = int(p.y) + 4
while y > int(p.y) - 20:
var n = client.node_name_at(Vector3(p.x, y, p.z))
if n != "air" and n != "ignore":
return {"y": y, "node": n}
y -= 1
return {"node": "nothing found"}'
Reading an entity's animation¶
call entity_animation args=[<id>] says what an entity's skeleton is doing,
for checking animation against what the server sent. Ids come from
inspect target=entities. tracks is what plays on the mesh, numbered from
1 as the Lua API numbers them, each with its frame, speed, range, priority,
loop flag and blend progress. server_tracks is what the server last set,
which differs from tracks while the local player's own animations play.
joints gives each joint's local transform as the tracks alone left it,
before bone overrides and Goanna's first-person posing, beside its rest
transform and an at_rest flag. Transforms are in the mesh's own terms:
mesh units, and Irrlicht's handedness rather than Godot's. It reads and
changes nothing.
Using the UI from inside the client¶
Forms, menus and keys are driven from inside the client, so no OS pointer or
keyboard is involved and nothing on the desktop moves. Each action is a
Godot InputEvent handed to Input.parse_input_event, the entry an OS event
takes: it updates Input's own state (a held key moves the player), then
goes through the window, the viewport, the GUI and _unhandled_input as a
player's would. A formspec button pressed this way sends the server what a
click sends. The code is project/agents/control_ui.gd; the formspec is read
through describe() in project/ui/formspec.gd, which only reads.
C="tools/goanna-control --port 30851"
$C key key=inventory # the I key: opens the inventory
$C ui_tree # what is open, and where everything is
$C ui_click text=Tools # a creative tab, found by its tooltip
$C ui_hover item=mcl_clock:clock # the tooltip of the slot holding it
$C ui_type name=search text=torch enter=true
$C ui_click 'slot={"list":"main","index":2,"location":"current_player"}'
$C ui_scroll item=mcl_core:stone amount=3
$C key key=Escape
ui_tree says which window is open (none, inventory, form,
pause_menu, settings_menu, death_screen), and for a form lists every
named element with its formspec type, text, tooltip, rectangle in viewport
pixels and state (focus, pressed, current tab, value), every inventory list
with its visible slots and what they hold (a scrolled out slot is counted,
not listed), the labels, the element with the focus and the tooltip on
screen. A Goanna menu is listed as its buttons and fields.
The actions take a target: name (a formspec element, with tab for a tab
header, as a caption or a number from 1), slot (list, index and, when
the form has the list twice, location), item (the first visible slot
holding it), text (a caption, a tab, a label or a named element's tooltip,
exact first, then ignoring case, then contained; more than one match is an
error unless nth picks one), or x and y. A target scrolled out of view
is refused rather than clicked through. ui_click takes button, double,
shift, ctrl and alt. ui_hover waits for the tooltip and returns it.
ui_scroll takes amount in wheel notches, negative for up. ui_type
clicks the field named, types text a key at a time, and takes clear and
enter. key takes a key name (Escape, I, E, 1, F5, Enter) or a
game action (inventory, menu, chat, forward, jump, sneak,
aux1, hotbar1 to hotbar10, dig, place), and action tap, press or
release, with hold_ms between.
Replies say what the pointer landed on (hit), which fields the form sent
(fields_sent, read from the form's own signal), which slots it acted on,
whether the server answered with a new form (form_rebuilt), and what is
open afterwards. That is the check that the server received a press: a
creative tab clicked this way comes back as a rebuilt form listing that
tab's items, and a stack moved into the hotbar shows up in the next
ui_tree, because the inventory it reports is the server's.
Mouse events carry the channel's own device id, which is how test mode tells them from a real pointer. Key events keep device 0, because Godot's built in actions, such as Enter submitting a field, only match the device they were defined for. A dropdown opens but its list cannot yet be picked from, and dragging a stack across slots with the button held has no command.
The overlay, so a watching human knows what is going on¶
A window that is not moving looks the same whether the client is wedged, the server has stopped answering, or the agent driving it is simply thinking. Telling those apart by watching has cost real time here, so a client with the control channel open draws a small overlay in the top right corner saying what it is doing:
The first line is the session's label, set with label <text> or with
GOANNA_TEST_LABEL at launch. The second is the command in flight and how
long it has been running, or, when nothing is in flight, how long the client
has been idle and which command finished last. A failed command says so and
the overlay turns orange until the next one succeeds.
How to read it: a number that keeps climbing under one command name is a
stall, in the client or in the server it is waiting on. A number that resets
is progress. idle climbing means the client is fine and whatever is driving
it has not asked for anything, so look at the agent rather than the client.
It is drawn as part of the HUD, and shot hides the HUD while it captures,
so the overlay never appears in a screenshot the tooling takes. That is
deliberate: it is for the person watching the window, and captures stay
clean. To photograph it, grab the viewport directly with run instead.
What it may and may not do¶
Everything that changes the world goes to the server as an ordinary chat
command, the same text a vanilla client sends, and needs the same
privileges. tp is /teleport, give is /giveme, weather is the
game's own command. Nothing here reaches past the protocol, and nothing
here asks for anything a vanilla client cannot ask for. That is the boundary
in AGENTS.md and the channel does not move it.
Because a server answers a refused command in chat and nowhere else, the
command verbs return server_said with what came back, and set refused
when it was a refusal. Without that, a missing privilege reads as success:
$ tools/goanna-control weather rain
{"sent": "/weather rain", "refused": true,
"server_said": ["You don't have permission to run this command
(missing privileges: weather_manager)."]}
What the channel does change locally is the view: the camera, the time of
day override, and the lighting and material settings the settings panel
already exposes. Those go through game_ui._apply_setting, the same path
the panel uses, so there is one implementation rather than two.
Not every game has every command. Mineclonia has no /spawnentity, so
spawn is refused there and a mob comes from give mobs_mc:sheep and
placing the egg. chat is the way through for anything a game spells
differently.
Cold verify, which is not optional¶
Tuning a value in a live process and then reporting it as working is not the
same as the committed code doing it, and AGENTS.md forbids the second
claim on the strength of the first. A live channel makes that mistake much
easier to make, so the channel is built to make it hard to make quietly.
The value of every setting is recorded before the first command lands.
deviations lists what has moved since, along with the time of day override
and any shader hot loaded with reload_shader, which is the sharpest case:
the frame came from code on disk, and code on disk is not necessarily code
that is committed. Every shot reply carries the same list, and writes it
into a JSON sidecar beside the PNG along with the camera pose, the time of
day and the block count at capture.
So the rule is: a reading taken with deviations listed is a lead, not a result.
Write the value into the source, relaunch with nothing set, take it again, and
report that one. README.md and docs/history/development-log.md only ever get
the second number.
One setting is expected to show up here on a completely untouched profile:
far_distance defaults to whatever the server's far rendering grant turns out
to be (docs/develop/launch-target.md task 2d), not to the fixed number its C++
field starts at, so the baseline taken on the first command (usually before a
grant has arrived) and the value once the world has streamed in legitimately
differ. That is the code adapting, not a live tweak; check
render_stats().far_grant against it before treating it as a lead worth
chasing.
Driving it from an agent¶
tools/goanna-mcp is an MCP server over the channel, so an agent can drive
the client as tools rather than by composing shell commands. Register it
with:
The preferred read tool is goanna_inspect. Its capabilities target
identifies the interface as goanna-dev/0.1 and lists the stable observation
targets. These cover the common diagnostic questions directly; goanna_run
remains the privileged escape hatch for a one-off investigation.
For a PBR release check, pass a game texture filename to the materials
target. resolved reports whether the albedo, _n and _s files were found
through the active Luanti texture source and their dimensions. built reports
how many rendered array materials actually have normal and specular arrays
bound. This distinguishes an enabled slider from a pack that is genuinely
loaded and connected to the shader.
It speaks MCP over stdin and stdout as newline delimited JSON-RPC, with nothing outside the standard library, so there is nothing to install.
Everything it exposes is reachable from a shell with tools/goanna-control
and tools/goanna-headless, because the channel is the interface and this is
a wrapper over it. What the wrapper adds is the session: starting clients,
rebuilding and relaunching them after a C++ change, and handing back a shot
that shotcheck.py has already looked at.
Clients are instances. goanna_session action=start launches Goanna from
any checkout or worktree (project), headless by default, on the control
port given (refused if taken) or on a free one, and returns an instance id
such as goanna-30851. Every other tool takes that id as instance, and may
leave it out while the session has exactly one client running. Every reply
starts with the instance and the control port it acted on, because two
sessions once drove each other's clients through a shared port for a whole
evening without noticing. action=start_vanilla runs the vanilla client,
action=list shows every instance on the machine and which ones this
session started, and action=stop stops only this session's own unless
given force=true. A client somebody started by hand is reached with
action=attach control_port=N, or, as before, by leaving instance out
when this session has none and one answers on 30800 (or
GOANNA_CONTROL_PORT). headless=false opens a window on the desktop, for
the rare case where the owner has asked to watch one.
A typical run, as tool calls:
goanna_session action=start project=<worktree> control_port=30851
port=30000 name=dev -> instance goanna-30851
goanna_ui instance=goanna-30851 action=key key=inventory
goanna_ui instance=goanna-30851 action=tree
goanna_ui instance=goanna-30851 action=click text=Tools
goanna_ui instance=goanna-30851 action=type name=search text=torch enter=true
goanna_shot instance=goanna-30851 hide_ui=false path=/tmp/inv.png
goanna_session action=start_vanilla port=30000 name=ref -> instance vanilla-ref-30000
goanna_shot instance=vanilla-ref-30000 wait_ms=20000 path=/tmp/ref.png
goanna_session action=stop instance=vanilla-ref-30000
goanna_session action=stop instance=goanna-30851
There are ten tools rather than one per command, because a tool schema costs context whether or not it is used:
| Tool | What it covers |
|---|---|
goanna_session |
start, start_vanilla, stop, restart, status, list, attach, and build=true to run cmake first. The only way to see a C++ change, and the way to confirm a reading on a clean client. |
goanna_status |
Where the client is and what it is holding, with the deviations list. |
goanna_inspect |
The structured observations: scene, render, materials, sky, entities, inventory, node. |
goanna_view |
teleport through the server, or position, look_at, pitch, yaw, fly. |
goanna_world |
Time, weather, spawn, give, chat. Reports what the server said and whether it refused. |
goanna_settings |
Get, set or list everything in the settings panel. |
goanna_shot |
Settle, capture, sidecar, and the shotcheck reading; method=gamescope for the composited virtual display, method=x11 for the window's own pixels, the default for the vanilla client. |
goanna_ui |
tree, click, hover, type, scroll and key, as in the section above. |
goanna_run |
A GDScript snippet in the client. The one that does not run out. |
goanna_command |
Any channel command by name: reload_shader, wait, help. |
After tools/goanna-mcp changes, a running Claude Code session keeps the old
server until it reconnects: /mcp and reconnect goanna, or restart the
session. Registration does not need repeating, since it names the file.
Vanilla reference frames¶
tools/goanna-headless vanilla --server 127.0.0.1:30000 --name ref (or
goanna_session action=start_vanilla) runs the Luanti Flatpak in headless
gamescope, joined straight to the server with --go, with a configuration
file of its own under ~/.var/app/org.luanti.luanti/goanna-headless/, so
the owner's own client settings are never touched. The sandbox gets no
Wayland socket, so it can only reach gamescope's X display.
A frame is taken with tools/goanna-headless shot <id> <path> or
goanna_shot, by one of two routes, neither of which involves a window, a
key or the pointer. x11, the default for the vanilla client, reads the
client window's own pixels from the instance's nested X display with
ffmpeg's x11grab, after finding the window with xwininfo; it refuses the
desktop's display. gamescope, the default for Goanna, is gamescope's own
screenshot of the virtual display (gamescopectl screenshot, sent to that
instance's gamescope socket and nobody else's). Under --software the
gamescope route showed the vanilla client's loading screens but returned
pure black once it was in game, while x11 returned the game; with the GPU
neither route has been tried on the vanilla client yet.
The vanilla client cannot be steered: it has no control channel, and
nothing here types into it. It looks wherever the server puts it, so frame
it from the server side (a spawn point, a teleport by an admin, a test mod),
and allow it time to load before the shot: under --software that is
minutes.
Limits¶
shot needs a real display. Godot's own --headless driver has only a
dummy renderer, so there is no viewport texture to save, the same
restriction tools/test/test-formspec.sh works around. Headless gamescope is a
real display as far as Godot is concerned, so under the launcher shot
works. The UI commands work under Godot's --headless too, which is useful
when there is no GPU at all, though its window is 64 by 64 until a run
snippet sets main.get_window().size.
One game client on the GPU at a time. On 2026-09-19 two headless gamescope
sessions started within a minute of each other left the NVIDIA driver
refusing every new Vulkan device until a reboot, and it has happened since
in other forms. The rule, the driver's failure signatures and what to do
about them are in docs/agents/agent-interfaces.md, "The GPU" and "When the
driver has failed".
gamescope does not exit when its child does and ignores SIGTERM. The
launcher's supervisor stops it with gamescopectl shutdown on the
instance's own socket and kills its process group only if that fails.
C++ changes still need a rebuild and a relaunch: project/goanna.gdextension
sets reloadable = false, which is right while the session runs on its own
thread. Shaders, GDScript, settings, materials and the camera are all live.
The channel makes the cold path cheaper rather than unnecessary: rebuild,
relaunch, tp to the same absolute position, time to the same hour, then
shot, and the A/B is honest because both halves were framed by the same
script rather than by memory.
Which matters, because the framing traps are real and repeat. A fresh
GOANNA_NAME spawns at a random point, so two names photograph two
different places. The server streams only blocks in the view cone of the
reported look direction, so a view facing away from the pose the client
reported sees nothing. Both are avoidable once the sequence lives in a
script instead of in a shell history.
Three more:
- Absolute positions.
GOANNA_VIEWpositions without a leading@are offsets from wherever the player happens to be, which drifts between runs. Use absolute ones (@x,y,z) for an A/B. - The camera follows the player. Setting the camera from a
runsnippet (cam.look_at,cam.rotation) does nothing visible:main._processrebuilds the camera basis from the player's pose every frame, so the snippet's pose is gone before the next draw. Usepose,lookorgoanna_view, or setmain.pitchandmain.yaw, which_processhonours, and readcam.global_transform.basisback after a frame if a measurement depends on where the camera points. - A fresh profile. Launch with
XDG_DATA_HOMEset to a scratch directory (goanna_session action=starttakes anenv) and Godot putsuser://under it: nogoanna.cfg, no store, every value the code's default, and the player's own settings untouched. Do not reuse that profile across renderers. One first saved under lavapipe picked the Low profile (parallax and SDFGI off) and kept it, and three GPU look reviews were judged on frames with the features under review switched off. Record the profile name and the values that matter with every frame; the render service does this for you (docs/agents/agent-interfaces.md, "The traps it handles").