Agent interfaces¶
Goanna has two agent-facing interfaces with different authority. They may share implementation utilities, but they are not two permission levels of one public protocol.
1. Game development interface¶
The existing control channel and tools/goanna-mcp are the game development
interface. It is a local, privileged test instrument. It may inspect renderer
state, move the camera, teleport a test player, change settings, capture frames,
reload shaders and execute GDScript. See docs/agents/control-channel.md.
The first versioned surface is goanna-dev/0.1. Its structured inspect
operation covers capabilities, the Godot scene tree, render state, active PBR
materials, sky, entities, inventory and world nodes. This keeps routine agent
work discoverable while the existing arbitrary run operation remains
available for exceptional development investigations.
This interface is unsuitable for gameplay agents. Loopback binding is useful
protection against remote access, but it does not turn eval, run or
arbitrary method calls into safe player capabilities.
Its UI commands (ui_tree, ui_click, ui_hover, ui_type, ui_scroll,
key) belong to it too. They push input events inside the client and read
the open form, which is test tooling, and they send the server nothing a
player's own clicks would not send. They are not a player agent action
vocabulary.
Rules for test clients¶
AGENTS.md states these rules in brief; this section is their detail.
Agents testing Goanna have taken over the owner's desktop while the owner was using it: clients opened in front of their work, took the focus and grabbed the mouse, once in the middle of a video call, and an agent tried to move a client's pointer with xdotool. Agents have also, four times, left the NVIDIA driver needing a reboot. So:
Two kinds of headless¶
"Headless" means two different things here, and the difference decides whether a client touches the GPU:
- Godot's
--headlessis Godot's own dummy display and renderer. Nothing is drawn and nothing touches the GPU, so there is no frame to save, but the scene tree, the control channel and its UI commands all work. Use it for forms, state and anything that needs no picture. - Headless gamescope is gamescope's headless backend: a compositor
with its own nested X display and no output. The client inside it
renders for real, on the GPU (or on lavapipe with
--software), and no window reaches the desktop.tools/goanna-headless, the MCP server and the render service all run clients this way.
The rest of this document says which one it means.
The desktop¶
- Test clients run in headless gamescope through
tools/goanna-headlessor the MCP server (goanna_session action=start), or under Godot's--headless. A window on the desktop only when the owner has asked to watch one. - Never inject input into the owner's display (
DISPLAY=:0,WAYLAND_DISPLAY=wayland-0, or whatever the desktop's are) with xdotool, ydotool or anything else. Drive clients from inside, with the control channel's UI commands. The vanilla client is not driven at all; it is framed from the server side and photographed through gamescope.
Benchmarks on the desktop¶
Frame rate, 1% lows, hitch counts and a CPU or GPU bound verdict depend on
the real present path: the desktop compositor, the display's refresh and
the driver's presentation queue. Headless gamescope has none of them, and
under it the GPU idles at low clocks between presents. So a benchmark that
reports absolute frame pacing, tools/bench/goanna-bench.py, opens a real
window, and that is the one exception to the rule above:
- Desktop benchmark runs are run by the owner, or by an agent only when the owner has said the machine is free.
- They take the GPU lock themselves for the whole run, waiting for it
(
--lock-wait, by default until it is free), and make the same checks astools/goanna-headlessbefore the first client: no other game client or compute job on the GPU and no recent driver errors.GOANNA_GPU_LOCKnames another lock file, and a lock the caller already holds is used.--dry-runtakes the lock and makes the checks without starting a client. - Agents may run headless A/B comparisons (
tools/bench/bench-local-play.py,tools/bench/far-baseline.pyagainst a headless client, the render service'stiming). They are valid only as relative results: one case against another in the same run, never against a desktop number. - Every benchmark report records its mode, desktop or headless.
goanna-bench.pyreads it from the client and prints it under the report's title;bench-local-play.pyrecordsmodeandrelative_onlywith every result;far-baseline.pyrecords them in its manifest. - A headless A/B result that will be acted on is confirmed with a desktop run before it is treated as settled.
Processes¶
- Stop processes only by the PIDs you started, or through the launcher,
which checks each PID against its recorded start time. Never by name:
pkill goanna,pkill -f luantiandkillall gamescopehit other agents' clients and the owner's own game. Apkill -fpattern also matches the command line of the shell running it, and has killed the agent's own shell. - Give every client its own control port and server port. The launcher refuses a control port that is taken; do not work around it.
- Leave nothing running: stop every client, server and gamescope you started before finishing. A client left running after its server had gone once held 3.4 GB and half the GPU for 20 hours.
The GPU¶
- Use the tools; they take the lock. Every rendered frame and every GPU
timing goes through the render service (
tools/goanna-render, below). Any other GPU client, a fixture or a client of your own, goes throughtools/goanna-headless. Both take the one GPU lock,/tmp/claude-1000/goanna-gpu.lock(GOANNA_GPU_LOCKoverrides it), and both check the card before they start, so following them is the rule; there is nothing extra to remember.tools/goanna-headless gpu-locksays whether the lock is free and who holds it. - One game client on the GPU at a time. A headless gamescope started beside another GPU client has wedged the driver: 2026-09-19 with two headless sessions, 2026-09-25 with one beside a windowed Godot another agent had open.
tools/goanna-headless gpu-freeexits 1 and names the client while a Godot, gamescope, Luanti or compute job (the owner trains models on the same card) is on the GPU, or while the NVIDIA driver has logged errors in the last 30 minutes. When it says busy, do not render at all: wait, or use--software. The launcher makes the same check and refuses; do not setGOANNA_SHARED_GPU=1to get past it.- Never build a gamescope command line by hand. Offline fixtures (the
material ramp, the plant ramp, probes) run through
tools/goanna-headless fixture SCENE --env KEY=VALUE ..., which makes the same checks, keeps gamescope itself on lavapipe and waits for the scene to quit. On 2026-10-02 a hand built one setVK_ICD_FILENAMESto lavapipe and was run beside the owner's game in the belief that it kept off the card. Those variables steer only the child: gamescope chose the NVIDIA device itself, and the driver needed a reboot. - When the owner wants the card back, stop your GPU work at once, check
with
nvidia-smithat nothing of yours is left, and only then say it is free.
When the driver has failed¶
A wedged driver keeps running the processes that already have a device and refuses every new one, so the desktop looks fine while every new client dies. Its signatures, all seen on this project's development machine:
- Kernel log
Xid 51thenXid 154(a function level reset requested), thenNV_ERR_RESET_REQUIRED. - Kernel log
NVRM ... NV_ERR_NO_MEMORY, then every new Vulkan device failing withNV_ERR_STATE_IN_USE(2026-09-27, with nothing else on the GPU). It did not recover on its own. - The same refusal after a system update put a new NVIDIA userspace under the still running older kernel module.
In each, Godot fails vkCreateDevice (VK_ERROR_INITIALIZATION_FAILED)
and may crash with a segfault at startup. What to do:
- When a GPU start fails, read
journalctl -k | grep -i -E 'xid|nvrm'before anything else. Retrying, freeing VRAM and cleaning up processes do not help, and a client started into it crashes at once. - Treat any of these as needing a reboot, and tell the owner. Do not reboot or restart anything yourself.
- Until then, use
--softwareor Godot's--headless;gpu-freereports busy for 30 minutes after a driver error in any case.
The launcher and the MCP server are described in
docs/agents/control-channel.md, under "Starting it" and "Driving it from an
agent".
The render service¶
Use the render service for every rendered frame and every GPU timing. Do not start a GPU client of your own.
Before it, every agent wanting a frame imported a checkout, made a fixture
world, started a server and a client, took a few frames and stopped, and
held the one GPU lock for 30 to 60 minutes to do it, most of that setup.
tools/goanna-render does the setup once and keeps it:
goanna-render servetakes the GPU lock (see "The GPU" above) and holds it for the service's whole life, so nothing else renders beside it. Under the lock it requires the card clear: nothinggoanna-headless gpu-freenames, no NVIDIA driver errors in the last 30 minutes, and no compute user innvidia-smithat is not a desktop program. It then starts one Luanti server (Mineclonia unless--gamesays otherwise) on a fixture world it rebuilds every time it starts, and one Goanna client in headless gamescope on the GPU, through the launcher with--cpu-compositor.goanna-render shoot JOB.jsonqueues a job and blocks until it is done, printing the result. Jobs from any number of shells queue and run one at a time, oldest first. With no service running,shootstarts one with the defaults (--no-startrefuses instead).goanna-render statussays what it is doing, what is queued, which build and profile the client holds and why it last restarted;goanna-render stopstops it.
It gives the card back on its own. Between jobs, and between the variants
and poses of a job, it polls the same checks. When the owner's own Godot, a python
compute job or a driver fault appears, it stops its client and server,
releases the lock, puts any interrupted job back at the front of the queue,
and waits until the card has been clear for a minute before taking the
lock again. It stops cleanly on SIGTERM, and after 20 minutes with no job
(--idle-minutes), because a client left running costs the owner.
--software runs the whole service on lavapipe, without the lock, for
testing the service itself. Its frames are useless for judging a look.
The world¶
The fixture is a singlenode world with Mineclonia's own Lua level
generator turned off (mcl_singlenode_mapgen = false, which otherwise
builds floating terrain over the stage), a 49 by 49 stone floor at the
stage, Luanti (1000, 31, 1000), and tools/render-fixture.lua and a fresh
copy of goanna_server_mod as worldmods. The server runs with
time_speed = 0, Mineclonia's weather cycle off, mob spawning off and
movement anticheat off (poses move the player thousands of nodes a second
as far as anticheat can tell, and it then reset the player and streamed
blocks around the wrong place). The far field is not granted on the
fixture, which has nothing past the stage (--far grants it): on the first
GPU run it kept every pose waiting out the settle timeout. --world-from
NAME copies an existing world instead, for a scene with real terrain
(frame: world in the job), and grants the far field.
The player starts, and waits between jobs, 1200 nodes from the stage, so
a job's nodes reach the client in blocks sent fresh rather than as edits
to blocks it already holds. Every teleport puts the camera at the target
first: the client's tp streams blocks with the camera still where it was
and tiers them against it, and on the first GPU run the stage arrived with
0 to 5 blocks meshed and stayed that way, so the frames were sky. An
arrival now also requires at least one block meshed.
GOANNA_RENDER_STATE=DIR gives a service its own queue and record, for a
second service on another world or game. The two still share the one GPU
lock, so one waits while the other renders; jobs go to whichever service
the shell's GOANNA_RENDER_STATE names. Each world has its own server
files under ~/.var/app/org.luanti.luanti/goanna-render/<world>/. A
service that is yielded when its own sources change starts again in place
with the new ones (the queue is on disk), because one left yielded for
hours kept yielding to lavapipe clients after gpu-free was fixed.
GOANNA_RENDER_STATE is also what keeps two agents' queues apart. Two
agents given the same scratchpad directory, each pointing it at
<scratchpad>/render-state, share one queue: on 2026-10-13 a job meant
for the standard world went into a shader pack agent's service on another
world. Give each service a directory no other agent will pick.
The standard world¶
test_world_do_not_delete is the world for Goanna's standard scenes: the
menu backgrounds, the graphics benchmark village and the scenes shader
packs are judged at. It replaces test_world, which held the earlier
scenes and was lost by 2026-10-13 with no copy and no record of its seed
or mapgen.
tools/dev/make-standard-world.sh makes it from nothing, and refuses if
a world of that name exists. It writes the world, starts one Luanti server
on it with no client, pregenerates a box with a worldmod that calls
core.emerge_area a mapchunk at a time (tools/dev/standard-world-pregen.lua),
waits for the server to shut itself down, removes that worldmod and
installs a fresh goanna_server_mod. The settings it uses:
- Mineclonia as installed (release 38561), with its own defaults: nothing
in the repository recorded what
test_worldused, so none are changed. - Mapgen v7, seed 20261013, Luanti's defaults otherwise. Mineclonia sets
mg_flagsitself (caves, nodungeons, light, nodecorations, biomes, ores) and its new structure generator (mcl_levelgen_enable_ersatz, on by default) places the villages. - One emerge thread.
- The box is x and z from -1600 to 1600, y from -112 to 207.
The seed fixes the place, not every node. On 2026-10-13 a small box around the origin made twice with the same arguments came out with different trees, ores and leaf litter: Mineclonia's own generation is not deterministic. Against the full world, that box had identical water and 98 per cent of its ground heights equal, and a box remade around the village at (560, 272) had the same number of beds, bells and lanterns over the same extent. A remade world puts the land, water and villages in the same places, but a frame framed against one particular tree may come out different. The world itself is the reference; do not delete it to remake it.
On 2026-10-13 (Luanti 5.17.0 flatpak, Mineclonia 38561) that was 6724
mapchunks in 22 minutes, 480 MB on disk. A different seed or box is
--seed, --radius, --ymin and --ymax; --name makes a scratch
world for trying one.
Render it with the service on a copy, with no fixture floor (otherwise the service lays its 49 by 49 stone stage at (1000, 31, 1000) in the copy):
GOANNA_RENDER_STATE=<a directory of your own> \
tools/goanna-render serve --world-from test_world_do_not_delete --floor ''
The scenes themselves are not chosen yet. The menu backgrounds and the
benchmark plans still name test_world's poses, which mean nothing in this
world, until scenes are scouted here and those tools are pointed at them.
For a wider landscape, serve --server-view-range 40
--server-far-distance 2048 raises the server's live terrain limit to 640
nodes and its far grant to 2048 nodes. The defaults are 16 blocks and 512
nodes. Match them with the job's profile.view_range and
profile.far_distance: a larger client setting alone cannot raise the
server's limit. Far terrain also needs --world-from or --far, and only
generated terrain is available in a copied world.
Both serve and vanilla accept --no-server-occlusion for landscape
references. Luanti's distant block culling assumes unloaded blocks behind
the loaded frontier are hidden. Disabling that optimisation can help a
wide static view fill, at the cost of sending more blocks. This changes
only the disposable server's configuration, not the Luanti engine.
Vanilla reference frames¶
After stopping the Goanna render service, tools/goanna-render vanilla
JOB.json --world-from WORLD --server-view-range 40 captures the same
world-frame poses through the headless launcher with the unmodified Luanti
client. It takes the GPU lock for the run and stops its clients and server
on exit. It uses a fresh copy of the world, fixes the player, camera and
time from the server, and sends no desktop input.
This command uses the job's out, poses, size and time, with clear
weather. It ignores Goanna's variants, PBR pack and graphics profile, and
refuses fixture node edits and statues. Frames and settings go under
OUT/vanilla/. The original game textures and Luanti's own shaders are the
reference. The live range is the server range times 16 nodes; vanilla has
no Goanna far summaries. There is no client streaming completion query,
so --settle-seconds gives a recorded fixed wait, 60 seconds by default.
Inspect the frames before treating that wait as enough.
A job¶
Coordinates are Luanti's, relative to the stage (its floor is at y -1),
unless "frame": "world" makes them absolute. The service negates z for
Goanna itself. A pose takes look_at (a point) or yaw and pitch in
degrees, yaw 0 looking along +z and 90 along -x, pitch positive looking
up; pos is the eye.
{
"label": "zombie, maps on and off",
"out": "/abs/path/to/output",
"tier": "high",
"size": [1280, 720],
"pack": "/abs/path/to/pbr_packs/mineclonia/textures",
"build": "/abs/path/to/a/worktree",
"env": {"GOANNA_DEBUG_LOD": "1"},
"profile": {"mat_micro_shadow": 1},
"time": 0.5,
"weather": "clear",
"nodes": [{"box": [[-2, 0, 5], [2, 2, 5]], "node": "mcl_core:stonebrick"},
{"pos": [0, 0, 7], "node": "mcl_portals:portal", "param2": 0, "swap": true}],
"lua": ["local p = P(0, 0, 9); set(p, 'mcl_pottery_sherds:pot'); ..."],
"statues": [{"entity": "mobs_mc:zombie", "pos": [0, -0.5, 3], "yaw": 180},
{"entity": "goanna_render_fixture:figure", "pos": [2, -0.5, 3],
"props": {"textures": ["character.png", "mcl_armor_chestplate_iron.png",
"blank.png"]},
"attach": [{"entity": "mcl_wieldview:wieldview", "bone": "Wield_Item",
"props": {"wield_item": "mcl_tools:pick_iron",
"is_visible": true}}]}],
"verify": [{"pos": [0, -1, 0], "node": "mcl_core:stone"}],
"poses": [{"name": "face", "pos": [0, 1.6, 0.8], "look_at": [0, 1.4, 3], "fov": 60}],
"variants": [{"name": "maps_on", "maps": true},
{"name": "maps_off", "maps": false},
{"name": "dusk", "time": 0.74}],
"crops": [{"name": "head", "rect": [540, 160, 200, 200]}],
"timing": {"rounds": 6, "burst": 600, "poses": ["face"]}
}
Everything but out and poses may be left out. build defaults to
main's checkout (project/bin there, and its GDScript as it stands);
pack to that checkout's pbr_packs/<game>/textures; tier to high;
size to 1280 by 720; time to 0.5 and weather to clear. Each variant
overrides any of build, pack, tier, size, env, maps, profile
(merged), time and weather, and every variant is shot at every pose.
frames: false skips frames for a timing only job.
For large landscape captures, terrain_wait: {"quiet_seconds": 30,
"timeout_seconds": 900} waits after each pose until the distant coverage
counters have stopped changing for 30 seconds. It fails the job instead of
capturing when the deadline expires. The settings sidecar records those
counters under settled.terrain. Cached block totals are excluded because
near/far ownership changes them without extending coverage. This checks
stable coverage, not that every region contains generated land: a copied
world's ungenerated edges can still leave gaps and partial areas. Inspect
the frame as well.
A node entry may give param2, and swap: true places it with
swap_node, so no constructor or destructor runs: a Nether portal set
with set_node is destroyed by Mineclonia's own callbacks. /rs_reset
puts every node back with swap_node and clears its meta. lua is a list
of Lua chunks (a string, or a list of lines) run on the server after the
boxes are placed, for node meta, a decorated pot's faces or a callback;
their scope adds S (the stage), P(x, y, z) (stage relative to
absolute), set, swap and save (each remembering the old node for the
reset), track(obj) (an object the reset removes) and statue(d) (a held
statue as below, for properties computed on the server, such as worn
armour from the game's own items; list it under lua_statues with its
entity and pos so the arrival checks it). A statue may give
props (object properties set after the entity activates), animation
([from, to, speed]), attach (entities attached to a bone, each with
its own props) and burn (set on fire for good; its on_step is
shadowed, so the burn never runs down). goanna_render_fixture:figure is
a statue with the player's own model and texture slots (skin, armour, a
third the game leaves blank), on Mineclonia only. An entity's position is
the bottom of its collision box, so a statue standing on the floor is at y
-0.5.
What costs what:
profile(any key the settings panel has,mat_*included),time,weatherand a pose'sfovare applied live and put back after the variant, excepttexture_size, which the client reads only when it joins: an override of it is written into the profile at launch.build,pack,tier,size,env,mapsandtexture_sizeare fixed at launch, so a change restarts the client: 32 to 41 s on lavapipe (92 s the first time, compiling shaders), 20 to 27 s on the RTX 3090, plus a one off import of a worktree that has never been opened (godot --headless --import, no GPU, minutes). A rebuiltproject/binis seen by its hash and restarts the client too. Variants that share a launch run together, so maps on and off is one restart, not one per pose. The result lists every restart and its seconds underclient_restarts.
Each frame is OUT/<variant>/<pose>.png, with the client's own sidecar
(<pose>.json, which lists deviations) and the service's
(<pose>.settings.json): the build (checkout, HEAD, branch, a dirty flag
and the files, and the hash, size and time of the library), the Godot,
Goanna, server and game versions, the adapter, the tier and every value
the client held at the shot read back from it, the overrides asked for,
the pack, maps, environment, time, weather, both coordinate frames of the
pose, the node and statue checks (with the blocks meshed at arrival),
whether the pose settled and how long it waited, the material counts with
normal and specular arrays bound, and the entity materials built with and
without a normal map, and any shader compile errors in the client log so
far (Godot compiles a shader when its material is first drawn; an error
fails the job, with the frames still written). The material counts cover
only the client's per texture material map, which node arrays do not use,
so on a node scene they read 0 whether the maps are bound or not; the
entity counts are the ones to trust. Crops are <pose>.<name>.png. timing writes
OUT/timing/timing.json (median and p95 of per draw GPU time per variant
and pose, with the range over rounds) and every round's draws as CSV.
OUT/result.json repeats what shoot printed, with the wall time.
The traps it handles, so a job does not have to¶
- Stale profile. A scratch profile first saved on lavapipe stayed on
Low with parallax off and invalidated three GPU reviews. Every launch
writes a fresh profile into a fresh
XDG_DATA_HOME, withGOANNA_NO_HW_DEFAULTS=1, then reads every value of the tier back from the client and refuses to shoot if one differs (far_distancefollows the server's grant and is only recorded). It also refuses an adapter that is not the card. - Maps off.
maps: falselaunches withGOANNA_NO_PBR=1andGOANNA_AUTO_BUMP=0andauto_bump=0in the profile, so no relief is inferred either, andGOANNA_DEBUG_ENTITY_PBR=1always. A maps off frame whose client log has any entity material built withnormal=truefails the job; the sidecar saysmaps_off_verified. - Empty
luanti/submodule. A build whose checkout has noluanti/textures/base/pack/blank.pngis refused: players would draw yellow. - z negated. Jobs are in Luanti coordinates; the service converts for
tp,pose,lookandnode_name_at. - Presented frame timing. Under headless gamescope the GPU idles at
low clocks between presents, so timing is back to back
force_drawbursts after 30 warm draws, variants rotated per round, as indocs/perf/low-tier-occlusion-2026-10-05/run.py. - Weather. Mineclonia's cycle is off, and each variant sets its weather for a million seconds and puts clear back.
- Missing meshes after teleports. A job teleports once, to
anchoror its first pose, and then moves only the camera. Before any frame every corner of every box, everyverifyentry and every statue is looked up in the client; if one does not match within 30 s it leaves for the parking place and comes back, up to three times, and fails the job rather than photograph a hole. - Statues.
/rs_statueholds the entity in place with itson_stepshadowed (no walking, burning or despawning) and spawns it again whenever its block is active and it is gone, so placing one while the player is away works. - A held player name. A stopped client can leave its player on the server until it times out, and the next client under the name is refused; the service waits for the server to log the player leaving.
- Clients left running. SIGTERM,
stop, the idle timeout and a yield all stop the client through the launcher and the server by the PIDs the service started.
What has been run¶
On 2026-10-06, only with --software (lavapipe, 640 by 360), because the
card was never clear of other agents' clients while this was written:
Luanti 5.17.0 server, Mineclonia (release 38561), Godot 4.5.1, main's
build at 00c39896 with two dirty files. The service came up in 75 s (server
19 s, client 41 s). Three jobs were sent from two shells at once and ran
one at a time in order: a stone wall at noon and dusk (209 s, no
restart), a zombie statue with maps on and off (259 s, one 37 s restart;
the maps off sidecar had pbr_disabled true, no normal or specular arrays
bound, auto_bump 0 and no entity material with a normal map), and a
timing job of two parallax variants (442 s, one 32 s restart back to maps
on; lavapipe's numbers are meaningless as timings). With --fake-gpu-user
listing a live dummy python3 process, the service yielded 5 s after the
job in hand finished, stopped its client and server, released the lock,
and came back 25 s after the process ended. Started without
--software beside another agent's client, it took the lock, found the
client, released the lock and waited without starting anything.
On 2026-10-06 on the GPU (RTX 3090, NVIDIA driver, Godot 4.5.1, the same
server and game, main's build at dec70408 and then 019bc7d1, each with two
dirty files, profile High read back at every launch, adapter "NVIDIA
GeForce RTX 3090", mat_parallax 1): the first runs drew sky, as above,
and the fixes in this section came from them. After them the service came
up in 20 to 21 s of client launch, and the same three jobs ran: the wall
in 71 s with no restart, the zombie in 101 s with one 23 s restart (maps
off verified, no entity material with a normal map), and the timing job in
233 s with one 23 s restart. Per draw GPU time on the wall at 1280 by 720
was 1.85 ms median with parallax on (1.85 to 1.85 over six rounds of 600)
and 1.66 ms with it off.
Not yet run: a yield in the middle of a job, which puts the job back at
the front of the queue; --world-from; a build other than main's.
2. Player agent interface¶
The player agent interface represents an ordinary participant in a world. The server remains authoritative and every action is subject to the same reach, collision, inventory, privilege, protection and rate limits as a human player. It has no arbitrary-code or arbitrary-client-method escape hatch.
The protocol reserves two useful scopes:
- Actor: an embodied individual. It receives local perception and submits ordinary movement, interaction, inventory and communication actions.
- Director: a settlement or faction decision-maker. It receives only the symbolic information that subject legitimately knows and submits priorities or goals which the game decomposes into work. It cannot place nodes, create goods or directly command unrelated actors.
band or crew may later become an intermediate scope. It is deliberately
not required by the first protocol.
These are authority scopes, not personalities. Identity, prose character descriptions, long-term memory, planning and model selection belong to the agent host. Goanna transports observations and legal actions; it does not implement an agent mind.
Protocol shape to preserve¶
Every request and observation carries:
- a protocol version;
- a session and subject identifier;
- a monotonically increasing observation sequence;
- the world/game tick when the state was sampled;
- the authority scope and advertised capabilities;
- the observation sequence on which an action was based;
- a structured result: accepted, completed, interrupted, refused or stale.
Capabilities are negotiated rather than inferred from a game name. A Kythen server may advertise settlement direction while another Luanti game exposes only embodied player actions.
Actor observations¶
The initial observation is intentionally modest:
- player position, orientation and physical state;
- pointed target and a bounded set of visible or otherwise sensed nodes;
- nearby visible entities;
- inventory and wielded item;
- chat and gameplay events since the preceding sequence;
- optional RGB/depth frame references.
Spatial memory distinguishes current, stale and unknown. Previously seen
terrain may remain in the agent host's persistent map, but the interface does
not reveal unseen server map data. This follows the useful lesson from
PERSIST: world, camera, action and rendered observation are synchronized
state, rather than an unstructured history of screenshots.
Actor actions¶
The first action vocabulary is:
- look and set movement controls;
- dig, place, use and attack through normal player input;
- select, move and use inventory items;
- send chat;
- wait for ticks, an event or action completion.
Navigation, crafting and other convenience skills may be added later, but must resolve through these legal actions. There is no teleport, hidden-node query, direct inventory mutation or server-command shortcut.
Director observations and actions¶
Director messages are reserved in v1 even if no game advertises them yet. They use stable subject IDs and symbolic snapshots/deltas rather than a camera frame. Likely observation domains are population, stock, work, known places, relations and reports. Likely actions are priorities, allocations, offers, postures and requests.
The vocabulary belongs to the game. For Kythen it should align with the A8 tier boundary: an actor may submit T0 actions, a settlement director T2 intentions, and a faction director T3 intentions. Higher-tier requests enter Kythen's normal decomposition and failure propagation; the interface never implements an alternative simulation path.
Roadmap¶
R0: Freeze the boundary¶
- Name the current channel the game development interface.
- Publish this authority and non-goal document.
- Reserve version, sequence, subject, scope, capability and result fields.
- Add tests that developer-only verbs can never appear in a player schema.
Deliverable: protocol examples and schema tests, no autonomous agent.
R1: Read-only actor¶
- Add a separate endpoint and wrapper.
- Negotiate capabilities.
- Return synchronized body, camera, inventory, visible-world and event state.
- Record deterministic observation trajectories for debugging.
Deliverable: an external program can observe one connected player but cannot act.
An experimental first pass is available with GOANNA_PLAYER_AGENT=1, using
loopback port 30850 (or set the variable to another port). tools/goanna-player
provides hello and observe, while tools/goanna-player-mcp exposes the
same operations to an agent host. It is independent of GOANNA_CONTROL;
enabling it does not enable the privileged developer API. R2 below extends
the same endpoint with actions.
R2: Embodied actor actions¶
- Add movement, look, interaction, inventory, chat and wait.
- Attach actions to observation sequences and reject stale targets.
- Exercise reach, protection and rate-limit parity against human input.
Deliverable: a scripted policy can play through ordinary mechanics. Planning, memory and autonomous goal selection remain external.
Status: a first version is in project/agents/player_agent_channel.gd, protocol
goanna-player/0.5, described below under "The player agent protocol". What
has been seen to work, and what has not, is listed at the end of it.
R3: Game extension seam¶
- Let a server/game advertise additional observation and action schemas over a mod channel.
- Keep generic actor primitives usable without a game extension.
- Prototype Kythen stable subject IDs and read-only settlement reports.
Deliverable: Goanna can carry game-specific agency without knowing Kythen's simulation types.
R4: Director pilot¶
- Map a small Kythen T2 subset to the extension seam.
- Route accepted intentions through Kythen's existing behaviour hierarchy.
- Verify embodied and unwatched resolution have the same economic result.
Deliverable: one settlement priority loop. No general autonomous settlement, faction diplomacy, culture generation or multi-agent society.
The player agent protocol¶
goanna-player/0.5, served by project/agents/player_agent_channel.gd. It
shares no code path with the control channel: no dispatcher, no eval, no
method call by name. project/tests/player_agent_boundary.gd fails if the
channel, tools/goanna-player or tools/goanna-player-mcp gains any of the
control channel's verbs (eval, run, call, tp, pose, inspect, time,
weather, set, shot, reload_shader and the rest), or if the channel's
source gains a dynamic call, a pose write or a second shell call.
Starting it and the token¶
Start the client with GOANNA_PLAYER_AGENT=1 (port 30850) or
GOANNA_PLAYER_AGENT=<port>. It listens on 127.0.0.1 only. At launch it
writes a fresh random token to user://player_agent_<port>.token, created
with mode 0600 before the token goes in, and deletes the file on exit. Every
request must carry that token, so another program on the computer cannot
drive the player without being able to read the user's files.
tools/goanna_player.py finds the file where Godot puts user:// for
Goanna ($XDG_DATA_HOME/godot/app_userdata/Goanna on Linux, falling back to
~/.local/share). GOANNA_PLAYER_AGENT_TOKEN_FILE or --token-file
points it elsewhere, GOANNA_PLAYER_AGENT_TOKEN gives the token directly,
and GOANNA_PLAYER_AGENT_PORT and GOANNA_PLAYER_AGENT_HOST say where the
channel is.
Requests¶
One JSON object per line:
The reply is {"id": 7, "ok": true, "result": {...}}. ok is false only
for a malformed request, a wrong token or an unknown command. A refused or
stale action is a result, not an error.
Observations¶
observe returns the protocol, session, subject, scope (actor), a
sequence that increases with every observation, the frame tick, and:
body: feet position, health, breath, grounded, in liquid, climbing, velocity, wielded slot and item, hotbar size;camera: eye position, pitch, yaw, look direction, field of view. Pitch is positive looking up; yaw 0 looks along -z and 90 along -x;pointed: what the crosshair is on, within the wielded item's reach, exactly as the client's own selection box shows it, with itsinfotextwhen it has one, the text shown in the corner while it is pointed at;nearby_entities: objects within 32 nodes that are inside the camera's view and have a clear line from the eye to their body or head, with no walkable node in the way, each with thenametagdrawn over it. The client is told about objects behind walls and behind the player; those are left out;inventory: the player's own lists, with stripped item descriptions;window: the open window, and for a form the slots on screen with their contents, the stack on the cursor, whether it may be closed, its named elements (below) and itslabels, the text on it that belongs to no element;hud: what the server has put on the screen, as Goanna draws it. Text elements with theirtextandspans; images with the texture string they show; status bars withvalueandmax; inventory strips; and waypoints, only while they are in front of the camera, with theirname, where on thescreenthey are (0 to 1 across and down), thedistanceas drawn and thelook(pitch and yaw) that would centre one. A waypoint's exact coordinates are not drawn and are not given. Bars the server has hidden, empty text, a compass and a minimap (which Goanna does not draw) are left out; the hotbar is inbodyandinventory;events: chat lines, action results and sounds sincesince_event. A sound event is every sound the client plays, whatever the player's volume or mute: itssoundname,gain,loop, itssource(server, or what the client made it for:place,use,damage, orfootstepfor another player's or a mob's), and for a sound in the world itsdistancein whole nodes and thelook(pitch and yaw) that would face it, orlocalfor one heard as if from inside the head. Its exact place is not given, since a person hears a direction and roughly how far. The player's own footsteps, jumps, digging hits and form clicks are left out: they say nothing the observation does not, and would crowd out the rest;visible_nodes, only when asked for ({"columns", "rows", "range"}, up to 24 by 16 rays and 32 nodes): the first node each ray through the screen meets, which is the surface the player sees there. Rays stop at unloaded nodes and look through the medium the eye is in (water under water);frame, only when asked for ({"width", "format"}, up to 1280 pixels wide,jpegorpng): a picture of the screen as the player sees it, world, HUD and open form together, base64 indata. A client started with Godot's--headlesshas no picture and says so.tools/goanna-player-mcphands it to the host as an image, andtools/goanna-player observe --frame PATHsaves it.
Text a game colours, with core.colorize or hypertext styles, comes as
text and spans, each span a run of one look: its text, its color
as #rrggbb, and in hypertext bold, italic, underline and the
action it belongs to. The colour is the one the game asked for; a game
that tells the player something by colour (Kythen marks the words a player
knows that way) tells the agent too. labels on a form are objects of
this kind. Chat comes without colour, because Goanna's chat shows none.
A form's elements are the named ones on screen. Each has its name and
formspec type, and as fits the type: text (a caption, a field's text, a
hypertext's text as read), disabled, editable, checked, items and
selected (a dropdown or text list, counted from 1), tabs and selected,
rows (a table's, each with the row number it sends and its cells),
value, min and max (a scrollbar), actions (a hypertext's links,
each with the action it sends and the text it shows) and tooltip.
Elements hidden or scrolled out of a scroll container are left out, as they
are from the player's view; scroll to reach them. A password field reports
its length and never its text.
Positions use Godot's axes: Luanti's x, y and -z, in nodes. Light and fog are not considered, so an object or surface in darkness, or far off under water, is still reported; this is the known way an observation can say more than a person would make out.
Actions¶
Every action except release must carry based_on, the sequence of the
observation it was decided on. The last 64 observations are kept.
| Action | Arguments | What it does |
|---|---|---|
look |
pitch, yaw, turn_pitch, turn_yaw |
Sets the view, pitch clamped to 89 degrees either way. |
move |
controls (forward, backward, left, right, jump, sneak, aux1), duration_ms |
Holds the controls for up to 30 s, or until release when duration_ms is left out. |
release |
control: all, buttons or one control |
Lets go. Ending a move lets go of every control it held. |
dig |
max_ms |
Holds dig on the pointed node until it breaks, then lets go. |
place |
One right click on the pointed node. | |
use |
One right click on whatever is pointed at, or on nothing to use the wielded item. | |
attack |
duration_ms |
Holds dig on the pointed entity. |
hotbar |
slot, from 1 |
Selects a hotbar slot. |
drop |
single |
Luanti's drop key on the wielded stack. |
inventory_open |
Opens the player's inventory form. | |
inventory_close |
Closes the open form, as Escape does. | |
inventory_click |
location, list, index, button, shift |
Clicks a slot of the open form. |
form_button |
name |
Presses a button, image button or item image button. |
form_field |
name, text, enter |
Replaces a field's or textarea's text, as typing does. It goes with the next event that sends fields; enter (fields only) presses Enter, which sends at once. |
form_select |
name, index or text, double |
Chooses a dropdown or text list entry, a table row (by its row number or a cell's text) or a tab. double is a double click. |
form_check |
name, checked |
Ticks or clears a checkbox. |
form_scroll |
name, value or by |
Moves a scrollbar, which also scrolls its scroll container. |
form_action |
name, action or text |
Follows an action link of a hypertext element. |
respawn |
Presses Respawn on the death screen. | |
chat |
text |
Sends one line of chat. |
wait (ticks, ms, event with since_event, or action, each with
timeout_ms) and action_status (action) are queries and need no
based_on.
The movement controls join main.gd's key state, and dig and place join the
mouse buttons, ahead of step_player and step_interact. So the action
goes through the same code as a person's input: the same reach, dig times,
punch interval, window gate (nothing moves while a form is open), and the
server's own privilege, protection, anticheat and rate checks. Inventory
clicks go to the open form's slot handlers, the ones a mouse click reaches,
and only for slots the form shows. Picking a stack up and putting it down
are two clicks, as with a mouse.
The form_ actions work an element through its own signal, the one a click,
a keypress or a wheel turn on it emits, so ui/formspec.gd sends what
Luanti sends for the same event: a button its name and caption with the
fields, a checkbox only its own state, a dropdown its text or index, a list
or table CHG: or DCL: with the row, a tab its number, a scrollbar
CHG: with its value, a link action: and its name. Only elements on
screen can be worked; a disabled button or a read only field is refused, a
type the action does not fit is refused, and an element that changed since
based_on, or a different form, is stale. A form action's effects give
the fields_sent, the window and form afterwards and the element as it now
is. A button_url or a link with a url still offers the address in a
dialogue, as for a person; the agent cannot answer it and it does no harm left
open.
These are refused outright: chat beginning with / (a server command),
more than 20 actions a second, more than 5 chat lines in 10 seconds, body
actions while a window or chat is open, while dead or with the free camera
on, respawn when the death screen is not up, and anything while the player
is not in a world. Goanna's own menus (pause, settings) are not driven.
Results¶
| Status | Meaning |
|---|---|
accepted |
Under way. A held move, or anything sent with wait: false. |
completed |
It happened. effects says what was seen to change. |
interrupted |
It started and stopped early: the crosshair left the target, a window opened, the time limit passed, or a release. |
refused |
It cannot be done, or the server undid it (a dig whose node came back). |
stale |
The target is not what based_on observed: the pointed node, entity or slot changed, or the observation is too old. Observe again. |
A dig is predicted by the client and undone by a server that refuses it, so
its result waits 1.5 s after the break. A node that comes back is
refused. place and use report what changed (a placed node, an opened
form, inventory counts) and give a reason when nothing visibly did.
Seen working¶
On 2026-10-01, Goanna from this branch against a Luanti 5.17.0 server
running Mineclonia on the same machine, Godot 4.5.1, in headless gamescope
with software rendering (tools/goanna-headless start --software), driven by
tools/goanna-player and small scripts over tools/goanna_player.py:
hello,observewith and without the token (refused without);look, thendigof the pointed dirt:completed, the inventory gained the drop;hotbar, thenplace:completed, the node appeared and the inventory count fell;digby a player withoutinteract: the server logged the refusal and put the node back, and the result wasrefused;digwith a node on screen but beyond reach:refused, nothing pointed;digbased on an observation from before the view turned:stale;movefor a time and held untilrelease, swimming included;inventory_open, twoinventory_clicks moving a stack, a stale click,inventory_close, andlookrefused while the form was open;chat, its line coming back as a chat event, and/grantrefused;dropof one item, the inventory count falling;attackwith a node under the crosshair:refused;- the MCP wrapper listing its tools, answering
hello, refusing a/tpchat line, and not knowinggoanna_player_eval; developer verbs sent straight to the socket answered as unavailable.
On 2026-10-02, the same set up against a Luanti 5.17.0 server running Kythen 0.1.0-b1 in a fresh world (mapgen v7, no baked terrain), Godot 4.5.1, in headless gamescope with software rendering:
usewith the spawn kit's journal wielded openedkythen:interface_journal;observereported its four buttons and its text as labels;form_buttonthrough journal, dictionary, journal again andkythen:dashboard, each sending the button's name and caption, and the dashboard's History view swapping the text shown;form_buttonon a name the open form did not show:refused;- the dashboard's
button_exitClose sendingquitand closing the window; inventory_openandinventory_closeagain, with no elements reported on Kythen's plain inventory.
Fields, checkboxes, dropdowns, text lists, tables, tabs, scrollbars,
hypertext links and respawn were checked only offline, by
project/tests/player_agent_forms.gd, which builds a real form with
ui/formspec.gd and works it through the channel with no server. A fresh
Kythen world has no village, so none of its forms with those elements could
be reached. Kythen's map did not open at all: it waits for an image sent
with dynamic_add_media, and Goanna's session did not then handle
TOCLIENT_MEDIA_PUSH, so the image never arrived and the server never
showed the form. A person playing Goanna met that too.
On 2026-10-04, against a Luanti 5.17.0 server running Kythen 0.1.0-b1 in a
fresh world, with Goanna started with Godot 4.5.1's own --headless (no
renderer, so nothing on the GPU), goanna-player/0.4:
hudreported Kythen's ground overlay: its two text lines, the second in the overlay's own colour, and the two filled images of its bar;usewith the map wielded openedkythen:mapview, which needs the pushed map image fetched and acknowledged: Goanna now handlesTOCLIENT_MEDIA_PUSH, asks for the file, loads it and sendsTOSERVER_HAVE_MEDIA;form_buttonon the map's Zoom to my region, a second pushed image and the region view;- the map's Close button sent
closeand left the map open: Kythen's handler returns without closing the form, which a person meets too.inventory_closeclosed it.
Later the same day, the same server with Goanna rendering on the CPU
(tools/goanna-headless start --software): observe with frame returned
pictures of the world with the HUD over it, and of the open map form, as
JPEG and PNG.
That picture showed the map as a flat placeholder. The client had joined
under a name that had opened the map before, in an earlier connection, and
Kythen keeps a per player note of the last map file sent
(engine/interface/map.lua) that it never clears when a player leaves.
On the next open it skipped dynamic_add_media for a file the new
connection did not have, as it would for a vanilla client, which keeps no
ephemeral media across connections either. Under a fresh name the file was
pushed, fetched and loaded at its real 128 by 128, and the form showed it.
On 2026-10-05, the same set up against a Luanti 5.17.0 server running
Minetest Game in a fresh world with damage on, two Goanna clients started
with Godot's own --headless (no renderer), goanna-player/0.5:
- the client spawned in the air and fell:
player_falling_damage(a sound Minetest Game does not ship, so not heard),player_damageand a landing step were played, and the damage sound came to the agent as alocaldamageevent; placeof dirt playeddefault_place_nodeand the agent heard it; placing into the node the player stood in played the dirt's place-failed sound, which Minetest Game leaves empty;digof that dirt played twodefault_dig_crumblyhits at the node and the dirt's dug sound;- the second client walked away along -x: the first heard its footsteps every 1.5 nodes, from 2 to 8 nodes off, at a yaw of 78 to 88 degrees and below eye level.
That run also found that Goanna sends a placement upstream's client refuses to send, one that would put a walkable node where the player stands; the server placed it. Its sound is right, the placement is not, and it is not yet fixed.
Not seen live: HUD speech from a villager, waypoints, infotext and nametags
(a fresh world has no village and no creature came near). They are covered
offline by project/tests/player_agent_forms.gd.
Not verified: attack landing on a mob. Every mob the test scripts aimed
at moved out of reach first (the spawn was beside a lake, and the
software rendered client ran at about five frames a second). Protection
was not exercised either: the test world had no protection mod, so only a
missing privilege was. respawn has not been seen against a server.
Explicit non-goals¶
This roadmap does not build an agentic civilization. It does not provide an LLM runtime, personality system, memory database, autonomous planner, relationship simulator, culture authoring agent, multi-agent coordinator or offline population service. Those may consume the interface later; none is a dependency of R0 through R3.
The developer interface remains the place for renderer and simulation diagnostics. The player interface remains a narrow, auditable bridge between an external policy and actions the game already permits.
References¶
- Microsoft Research, Project VEGA, for persistent autonomous characters, player influence and collaboration as a possible consumer model: https://www.microsoft.com/en-us/research/project/project-vega/
- Garcin et al., Beyond Pixel Histories: World Models with Persistent 3D State, for synchronized world-frame, camera, action and rendered observations: https://arxiv.org/html/2603.03482v2
- Kythen
docs/a8-behaviour.md, for its T0–T3 authority and decomposition boundaries. Kythen remains a separate game and Goanna does not depend on it.