The director¶
The director lets a language model run a Luanti world the way a game master runs
a table. It watches what players do, stages encounters, offers quests whose
conditions the game checks, speaks as creatures and characters, and keeps story
arcs and relationships going across sessions. The reference points are Left 4
Dead's AI director, which paces threats against each survivor's stress, and a DM
running a Neverwinter Nights module, who possesses creatures and talks to
players in character. It is not a way to drive a player avatar. Walking, digging
and crafting belong to the player agent interface in
docs/agents/agent-interfaces.md, and a model that has to walk to a village to
change it makes a poor game master.
One rule holds the design together: the model proposes and the game decides. Every action is an intent that Lua validates against the game's own rules, the operator's budgets and the world's protection before anything happens, and a deterministic pacing layer decides when. The model is never in the per step loop, and the game never waits for it.
Status: Phase 1, scoped to the first playtest, was built on 1 October 2026 and has been tested by a script, not yet by a model or by players. The catalogue, rewards and structures followed on 5 October 2026 and game rulesets on 6 October 2026. This page describes what is built. The director design of 19 September 2026 holds the full model, the reasoning behind it and the decisions the maintainer has settled since. The director log records what each test ran on, which server and game, and what it showed. Setting up a director is the guide for server owners.
In brief¶
- Server half: a new capability of
goanna_server_mod, off unless the operator enables it, and on for worlds Goanna launches. - MCP half:
tools/goanna-director-mcp, an MCP server with onlydirector_*tools. The design put them intools/goanna-mcp; see MCP half for why they are separate. - Transports: a seat (a headless Goanna client on its own account) relaying
over a private mod channel, or the server mod calling the MCP service over
Luanti's HTTP API. The same messages travel on both. The decided transport
needs one amendment: director traffic cannot ride
goanna:v1itself, because Luanti relays every message on a channel to every client on it. See Transports. - Three layers: an engine layer that works in any game, framework adapters detected at runtime (mob, armour and weather frameworks), and game rulesets that replace generic actions with better ones. Kythen's is proposed in detail.
- Verified on real servers: biome queries and where they go wrong, decorations and mob spawn tables per biome, the item catalogue, entities in an area with stable GUIDs, spawning, teleporting, naming, freezing and targeting mobs through mcl_mobs and mobs_redo, a removal hook that works in any game, weather through mcl_weather, the HTTP transport, and the real cost of the async environment.
- Not verified: anything that needs a connected player, the mod channel transport end to end, creatura, VoxeLibre's mcl_mobs, and any model.
Phase 1 as built¶
Built on 1 October 2026 for the first playtest, and tested by a script that
plays the model's part (below). No language model has driven it yet, and no
tester has played it. Nothing has moved in README.md.
Files¶
goanna_server_mod/init.lua requests the HTTP API at load, when
goanna_director is true, and hands it on
goanna_server_mod/director/
init.lua settings, conf file and token, session, the hook table
logic.lua ring buffer, budgets, rate limits, pacing, composition,
gear score, memory; no engine calls (unit tested)
events.lua engine callbacks, per player state, the one second tick
summaries.lua player and region summaries, status, capabilities
intents.lua the intent pipeline, queries, stop and undo
commands.lua the joiner notice, /director, the opt out
audit.lua the audit log
http.lua the HTTP transport
adapters/mcl_mobs.lua Mineclonia's mcl_mobs and mcl_armor
tools/goanna-director-mcp the MCP service and HTTP endpoint
tools/test/test-director-logic.lua unit tests for logic.lua (LuaJIT)
tools/test/test-director.py the end to end test
The layout differs from the proposal in Server
half: pacing and memory are small
enough to live in logic.lua and intents.lua, there are no quests, and there
is no channel transport. project/net/local_server.gd lists the director's
files in GOANNA_SERVER_MOD_FILES (creating the subdirectories as it copies),
writes goanna_director = true and secure.http_mods = goanna_server_mod for
every world it launches, and the vendored copy in
project/vendor/goanna_server_mod is kept identical, which
project/tests/local_server_terrain_diffusion.gd checks.
Transport¶
Since 5 October 2026 a world started from Goanna uses the file transport
(director/filelink.lua), and HTTP is the choice for a server on another
machine (goanna_director_transport = http). Goanna's bundled Linux server
is built without curl, so on a machine with no other Luanti the HTTP
transport could never work, and a player setting the director up had to
find secure.http_mods in a configuration file the launcher rewrites. The
file transport carries the same envelopes and messages through
<world>/goanna_director/link/: one file per message in in/, one batch
per server step in out/, and a heartbeat each way (director.json,
server.json). Each file is written whole and renamed. The inbox is read
on every server step, so a round trip, measured with the shell bridge
against the bundled server, took 0.08 to 0.2 seconds, including starting
the command each time. The catalogue, rewards and structures test
(51 checks by then) passed over it, against the Flatpak server.
Writes are a few small files a second at most, and none while nothing
happens. Kythen's villages, if each gets an agent, share one service and
one link, told apart by the envelope's scope.
The HTTP transport, as HTTP proposed:
POST <url>/v1/push, body{"v": 1, "session": "...", "batch": [...]}, where each element is an envelope (v,kind,scope,session,t,body). Kinds sent:hello(session,last_seq, capabilities, open encounters, cast characters, stop state),events(withseqandbody.events,body.gap),resultandreply. The endpoint answers{"known_session": bool}; false makes the server say hello again, which is how a restarted MCP service picks the session up.GET <url>/v1/pull?scope=gm&after=N&wait=20&session=S&instance=I, a long poll. The answer is{"v": 1, "messages": [...], "auth": A, "instance": I}where each message is{"id", "req", "kind": "act" | "query", "type", "args", "based_on", "reason", "scope"}, andAis the hex SHA-256 oftoken:session:after. The server ignores an answer whose proof is wrong, so a process that happens to hold the port cannot drive it. Message ids belong to one endpoint process (instance); a new instance starts them again.- Both directions carry
Authorization: Bearer <token>. The endpoint binds 127.0.0.1 only. - A result body is
{"id", "type", "status", "reason", ...}with the statuses of Envelope. Reasons used so far:schema,not_online,opted_out,stale,budget,entity_cap,no_fauna,unknown_mob,denied,budget_too_small,pacing,no_place,no_adapter,name_taken,name_in_use,hostile_npc,unknown_speaker,speaker_gone,out_of_earshot,no_listeners,rate,too_long,memory_text_off,unknown_action,stopped.
The director counts as connected only once a pull answer carries a valid proof. Players are told it is active from then, not before.
MCP tools¶
tools/goanna-director-mcp --world <world> reads the url and token from the
world's goanna_director.conf and serves 17 tools, and since 6 October 2026
one more for each intent and query a game's ruleset registers (Rulesets as
built). They were reworked on 5
October 2026 for a model's sake: a menu of tools each with an exact schema
is easier to call correctly than a few tools whose arguments depend on one
another.
| Tool | Message |
|---|---|
director_status |
a short brief: connection, game, which features are on, budgets left, players' phases, characters and their orders, the catalogue fingerprint; detail: true gives the full status query and the hello |
director_events |
the events buffered since the model last asked, waiting up to wait_s, with results of queued intents that landed later |
director_player |
player query: the summary, with its region summary |
director_catalogue |
catalogue query, cached by fingerprint |
director_memory |
memory query |
director_stage_encounter |
stage_encounter |
director_cast_npc |
cast_npc |
director_move |
order with a goal: hold, watch, go_to, stay, patrol, follow |
director_attack |
order attack |
director_build |
order build |
director_interact |
order hold_item or offer_trade |
director_speak |
speak |
director_remember |
remember |
director_grant_reward |
grant_reward |
director_place_structure |
place_structure |
director_undo |
undo |
director_stop |
stop |
- One vocabulary.
npcis the character who acts,playeris whom it concerns,targetis whom an act is aimed at,atis always a point[x, y, z], andnearwithdistance([min, max]) always means "find a place near this player". A follow range isrange. A character'sspeaktoallmeans everyone within its earshot, as the narrator's means everyone. - Follow ups. An act that is
acceptedorqueuedcomes back withfollow_up, naming the event its outcome will arrive as and the act id to match (npc_order,structure_placedorstructure_failed,encounter_started,encounter_ended). A standing goal (hold, watch, stay, patrol, follow) says it reports only when it fails or is replaced. - Instructions. The service's MCP
initializeanswer carries a short guide for the role: start with the brief, search the catalogue rather than guess names, read events before and after acting. - Roles.
--role gm(the default) offers every tool.--role voiceoffers status, events, player, memory, speak and remember, for an agent that only speaks for characters;--role watcheroffers only the reading tools. The role is the menu, not the authority: every act still carries thegmscope and the server mod checks it, so per scope limits on the server side remain part of the sub-director work.tools/goanna-director-cli --role voiceserves a role on a socket of its own.
An act carries based_on, the last event sequence returned to the model.
What it does¶
- Encounters.
stage_encountertakesnear,budget,theme(a mob category, defaultmonster) ormobs(names),when,distance,hidden,leash_sandvalid_for_s. The budget is capped at the player's encounter ceiling,goanna_director_encounter_baseplus the gear score timesgoanna_director_encounter_per_gear, and must fit what is left of the hourly points. Mobs come from the player's biome's fauna or the named list, minus the deny list, drawn by weight with aPcgRandomseeded from the world seed and session. Each is placed on loaded, clear, walkable, unprotected ground, out of the player's sight unlesshiddenis false, away from static spawn and from every opted out player; made persistent; given the director's targeting rule; and removed when the leash runs out, the player leaves, dies or opts out, or the encounter is undone. Outside a build up,when: "now"is refused andnext_build_upis queued. - Characters.
cast_npcwas not in the design. A character needs a body to speak from, and a fresh world has no villagers near the player, so the director may spawn a peaceful mob (defaultmobs_mc:villager) a few nodes from a player, named, and held in place, or name an existing mob by GUID. Monsters are refused, and so is a name any player account has. - Speech.
speaksends one chat line. A character's line readsGrimbold (NPC): textand goes only to players withingoanna_director_earshotof its body; narration reads[Narrator] textand goes to one player or all. Escape sequences and newlines are stripped, so a line cannot recolour itself or pose as another speaker. - Memory. Per character and player, for the session only: when they
met, lines spoken, times addressed, a disposition, and facts. Facts come
from events (
saw alice kill a zombie,was killed by alice) or fromremember, capped bygoanna_director_memory_linesandgoanna_director_memory_chars, oldest first out, each in the audit log. Annpc_addressedevent carries the character's memory of the speaker. - Pacing is the layer in Pacing, per
player, with the thresholds as settings and a short relax on joining
(
goanna_director_join_grace). - Holding a mob. The probe froze a mob by shadowing
on_step. That turned out to be unsafe: a villager killed while held that way crashed the server, because its murder report reads a cache onlyon_stepcreates. A held character now uses mcl_mobs' ownstupefiedstate, which keeps physics, damage and death running and only stops the AI.
Orders for characters¶
An order gives a cast character a goal it carries out by itself
(goanna_server_mod/director/orders.lua; the tools are director_move,
director_attack, director_build and director_interact), so the model
says what and the
character works out how: hold, watch a target, go_to a point or a
target and then stay, stay at a point and walk back when moved,
patrol up to twelve points with a pause at each, follow within a
distance range (a larger range hangs back), attack, hold_item and
offer_trade. Each ends with an npc_order event (arrived, lost, leash,
failed, replaced). Movement uses mcl_mobs' own pathfinder with the
character's AI still off, so it never wanders from an order; a held mob
walks waypoints but skips the path search itself, so the adapter advances
the search. director_status reports each character's position and order.
Attacking is for a mob that can fight: a villager has no attack and is
refused. A monster can only be a character when cast with armed: true,
which charges and paces it as an encounter on the player it is cast near
(ceiling, hourly points, build up). An attack on a player passes the same
checks again; the attacker's AI runs with the director's rule as its only
target source, so it fights whom it was ordered to and nobody else, and it
is held again when the target is gone, the player opts out or leash_s
ends. Trading sets a profession when asked (or farmer when it has none),
gives the villager a little experience so mobs_mc does not take the
profession back for want of a job site, and opens its trades on the
player's screen, at most three times a minute per player.
Tested on 5 October 2026 (Mineclonia, Luanti 5.17, a CPU rendered Goanna
client as the player): watch, hold_item, go_to with arrival reported and
stay after it, patrol visiting both points, follow (see the correction
below),
offer_trade opening the trading form on the player's screen, and a
villager refused as cannot_attack. An armed vindicator was refused as
over_ceiling (cost 14 against the player's 10), as it should be; an armed
husk (cost 5) was accepted, ordered to attack the player and took them
from 20 health to 17, stood down when its 20 s leash ended, then killed a
cast villager when ordered to, and was undone.
Correction, found later the same day: that run moved the player with
/teleport tester x y z, which Luanti refuses without saying so (it takes
x,y,z with commas), so the player never moved and "follow" was only shown
for a target already in range. The test in Catalogue, rewards and
structures teleports correctly, checks
that the player really moved, and saw a cast villager follow the player over
about 12 nodes to within its range.
Not verified¶
- Any language model driving it, and any playtest with people.
- Armour in the gear score: only the weapon half was exercised.
goanna_director_spawn_rules = natural,goanna_director_sees = coarse,goanna_director_chat = all, and the exclusion zone around a player who opted out (the refusal for the opted out player was tested, the zone for others near them was not).- A world launched from Goanna's menu with the director on. The deployment
of the files is covered by the existing headless test; the two settings
local_server.gdwrites were read, not run. - A server restart mid session,
stalefrom a leave, and encounters ending by leash. - Mobs other than skeletons and villagers, and any game but Mineclonia.
Catalogue, rewards and structures¶
Designed on 5 October 2026, after the orders. A director that only stages fights and speaks runs out of things to do. It also wants to know what the world contains, to give a player a named or enchanted item, to put a house or a ruin somewhere, and to have a character build one. This section is the design. What has been built and tested is recorded under each heading as it lands.
The rule throughout is the one in Framework
adapters: the core works on
the engine alone, and an adapter adds what only a framework knows. Every new
part below has a core that runs in any game and degrades plainly where no
adapter matched, and a Mineclonia adapter that adds the game's own enchantments,
containers and structures. Adapters are chosen per kind (mobs, items,
structures), each by detect() testing the tables it needs, so a VoxeLibre or
Minetest Game adapter is a new file, not a change to the core.
Catalogue¶
The model should not be sent every item at every turn: Mineclonia registers about 3,000 items, 81 mobs and 50 structures. The catalogue is a query that answers in pages, and a fingerprint that tells the model when what it already knows is out of date.
- Built from the engine. Items, nodes and tools come from
core.registered_items, with their mod, kind, description (first line, escapes stripped) and groups. Mods come fromcore.get_modnames()and the game fromcore.get_game_info(). Entities come fromcore.registered_entities. Items hidden from the creative inventory (not_in_creative_inventory) are left out unless asked for, because games use them for technical nodes. - Enriched by adapters. The mob adapter marks which entities are creatures, with category and cost. The items adapter lists enchantments with their maximum level and whether they are a curse or treasure, and says which items can carry them. The structures adapter lists the game's own structures with their size.
- Query.
cataloguetakeskind(item,node,tool,creature,enchantment,structure,mod),text(matched against name and description),group,mod,limit(default 40, at most 200) andcursor. Each answer is one short record per entry, the total, and the next cursor. - Overview. With no arguments it returns the game, the mods, the fingerprint and counts by kind and by mod, so the model knows where to look before it searches.
- Fingerprint. A hash of the game, its mods and every registered name.
It is in the hello and in
status. The MCP service caches answers by fingerprint, so a repeated question costs the server nothing until a mod changes.
Rewards¶
grant_reward makes an item and puts it where a player can choose to take
it. It is never put into a player's inventory, because a player has to be
able to refuse a gift from a machine and an inventory can be full.
- The item. Any catalogue item and count, an optional name, up to four
short lines of description, and, through the items adapter, enchantments
with levels. The core sets the name and lines through item metadata, which
every client shows. The Mineclonia adapter names an item the way its anvil
does (the
namefield, thentt.reload_itemstack_description) and enchants it withmcl_enchanting.enchant, aftermcl_enchanting.can_enchantaccepts each enchantment. Text is cleaned like speech. - Delivery.
drop(the core: an item entity on the ground near the player, made persistent until taken),container(a container node placed on clear ground near the player and filled; the adapter names the node,mcl_chests:chest_smallin Mineclonia), ornpc(a cast character walks to the player and drops it in front of them, holding it on the way). - Budget. Rewards have their own hourly points
(
goanna_director_reward_points_per_hour), separate from encounters, so a generous director cannot buy fights and a violent one cannot buy loot. The cost is the item's value times the count, plus each enchantment's level. Value comes from the adapter where the game has a notion of it, and otherwise from the engine: a tool by its damage and dig level, anything else one point per full stack. Each reward is also capped atgoanna_director_reward_maxpoints, andgoanna_director_reward_denylists items that are never granted. - Undo. Removes the item or container if nobody has taken it, and restores the ground under a container. Once a player has picked it up it is theirs, and undo says so.
Structures¶
place_structure puts a building in the world. It is the largest change a
director can make, so it is off unless the operator sets
goanna_director_structures = true.
- Two sources.
- The game's own through the structures adapter: Mineclonia's
mcl_structures.place_structurewith the game's loot and setup. Only structures whose size the adapter can work out before placing are offered, which in Mineclonia means those built from schematic files (read withcore.read_schematic); structures made by a function (the igloo, geodes) are not. - Authored by the director in the core: a palette of one character
keys to catalogue nodes, and layers from the bottom up, each a list of
rows along z, each a string along x. A space leaves the world's node, and
airclears it. Placed withcore.place_schematic. Nodes that hurt (damage_per_secondabove zero), explode (grouptnt) or are hidden from the creative inventory are refused, so an authored schematic cannot be a trap. - Where. At a position or near a player, on ground that is loaded,
wholly unprotected (
core.is_area_protected), clear of every player's body, away from static spawn and from players who opted out, and in no mapblock a player has dug or built in. The director records which mapblocks players change, in mod storage, from the moment it is first switched on in a world; changes from before then are not known to it, which is why protection is checked as well. - Budget.
goanna_director_build_nodes_per_hourcounts nodes changed, andgoanna_director_structure_max_volumecaps one placement's bounding box. - Undo. Before placing, the box (plus a margin, since the game's structures may lay a foundation) is read with a VoxelManip, with every node's metadata. Undo writes it back, except nodes a player has changed since, which stay as the player left them. Anything stored in a container the structure created is dropped on the ground rather than deleted, so a player loses nothing they put there. Creatures that appeared in the box are removed. Undo lasts for the server session, like every other undo record.
Building¶
The build order has a cast character put up a schematic over time: the
same two sources and the same checks, budget and undo snapshot as
place_structure, all taken when the order is accepted. The character walks
to the site and places a few nodes a second
(goanna_director_build_rate), bottom layer first, holding the node it is
placing, and skips any position a player has changed since the order began.
It reports progress in status and ends with an npc_order event. A game
structure is built from its schematic alone, so it has no loot. A character
builds whatever its body is: a villager is a builder, but nothing here gives
it an inventory to take the nodes from, so the director's budget is what the
nodes cost.
Rulesets as built¶
Built on 6 October 2026, so that a game or mod can add its own intents,
queries and characters without editing Goanna. The first consumer is
DorfCraft's fortress director (its docs/director-integration.md, "Proposed
Goanna changes", items 1 to 4); Kythen is the next. Items 5 to 8 of that list
(a memory hook, adopting cast characters, make_item and a voice scope) are
not built; see Not built yet.
The code is goanna_server_mod/director/rulesets.lua, with the schema check
and name rules in logic.lua, and the tool generation in
tools/goanna-director-mcp.
Registering a ruleset¶
A mod declares optional_depends = goanna_server_mod and registers at load
time, only when the table exists, so it loads unchanged on a server without
Goanna's mod or with the director off (goanna_director is then absent):
local api = rawget(_G, "goanna_director")
if api then
local ok, why = api.register_ruleset("fortress", {
description = "Fortresses: officers, workshops and their books.",
capabilities = {}, -- any table, shown to the model as is
points_per_hour = 20, -- optional; caps what costs add up to
intents = {
work_order = {
description = "Put an order in a workshop's queue.",
-- The JSON Schema subset below. The same table is checked
-- here and given to the model as the tool's input schema.
schema = {type = "object", required = {"fortress", "item"},
properties = {fortress = {type = "string"},
item = {type = "string"},
count = {type = "integer", minimum = 1, maximum = 64}}},
scope = function(scope, args, ctx) end, -- ok, rule, detail
subjects = function(args, ctx) end, -- {player names}
cost = 1, -- or function(args, ctx) -> n
rules = function(args, ctx) end, -- ok, rule, detail
places = function(args, ctx) end, -- {positions}
paced = false,
follow_up = "work_order_done", -- an event name
-- result table (status "accepted" for something that
-- carries on, else "completed"), and an undo record; or
-- false, rule, detail to refuse after all.
apply = function(args, ctx) end,
undo = function(record, ctx) end, -- fields
},
},
queries = {
fortress = {
description = "A fortress's summary.",
schema = {type = "object", required = {"id"},
properties = {id = {type = "string"}}},
answer = function(args, ctx) end, -- any table
},
},
undo = function(record, ctx) end, -- for intents without their own
on_stop = function(by, reason) end, -- /director stop or director_stop
addressed = function(player, message) end, -- nil or {npc, data}
speaker = function(name) end, -- nil or {name, label, pos, remote}
speak = function(speaker, text, listeners) end, -- ok, rule, detail
spoken = function(speaker, text, delivered) end,
})
end
register_ruleset returns true, or false and why, and logs the reason as an
error. Nothing is registered unless the whole definition is sound. Ruleset,
intent and query names are 1 to 32 of a-z, 0-9 and _; a ruleset may not
be called director, and an intent or query may not take a name the director
or another ruleset already has. Registration after the server's first step
is refused, so the first hello already lists everything. ctx is
{ruleset, act, scope, based_on, reason, queued}; act is the id
director_undo takes. Every hook runs under pcall: an error refuses that
intent as error, answers that query with an error, or is ignored for chat,
and is logged as a warning. It never stops the director.
The schema subset: type (object, string, number, integer,
boolean, array), properties, required, additionalProperties =
false, enum, minimum, maximum, maxLength, items, maxItems and
description. Arguments outside the schema are passed through unless
additionalProperties is false. Luanti writes an empty Lua table as JSON
null, so the MCP service restores empty properties and required.
The pipeline¶
A ruleset's intent runs through the same nine steps as the director's own, and the result and audit line have the same shape. What each step asks of the ruleset:
- Schema. The director checks the arguments against
schema; a failure isschemawith adetailnaming the argument. - Scope. Every scope but
gmis still refused for every intent, the ruleset's included.scope(scope, args, ctx)may then refuse whatgmasks about this subject, asnot_in_scopewith the ruleset'srule.subjects(args, ctx)names the players the intent concerns: one who opted out refuses it asopted_out, and one who joined, left or died since the model'sbased_onmakes itstale, as for an encounter. - Stop. Everything is refused while the director is stopped.
- Budget.
cost(a number, or a function of the arguments) is charged to the ruleset's own hourly points, separate from encounters and rewards, and only when the intent is applied. The operator'sgoanna_director_<ruleset>_points_per_houroverrides the ruleset'spoints_per_hour; with neither, costs are reported and not capped. - Rules.
rules(args, ctx)returns true, or false with the ruleset's own reason, which the model receives as reasonruleswith thatruleanddetail. - Place. Every position
places(args, ctx)returns must be loaded, unprotected (core.is_protectedfor nobody), and clear of static spawn and of players who opted out (not_loaded,protected,spawn,opted_out_player_near). - Pacing. A
pacedintent waits in the director's queue until every online subject is in a build up, and comes back as a late result, or is refused aspacingwhen its arguments saywhen = "now". It expires aftervalid_for_s(at mostgoanna_director_queue_s), and stop cancels it. - Apply.
apply(args, ctx)does the work through the game's own entry points and returns its result fields and an undo record, which must be plain data, since it goes into the audit log.director_undowith the act's id calls the intent'sundo(or the ruleset's) with that record. - Audit. One line per intent, with the arguments, the outcome, the result as effects, the subjects and the undo record.
Queries are checked against their schema and answered by answer(args,
ctx); whatever they leave out for players who opted out is the ruleset's
to leave out (goanna_director.opted_out below).
on_stop(by, reason) is called when the operator or the model stops the
director, after the director has removed its own creatures, so a ruleset can
end what it started for the director (DorfCraft's sieges). Each ruleset's
intents, queries, descriptions, schemas, capabilities, points left and
whether each intent is paced or undoable are in the capabilities query and
in every hello, under rulesets.
Characters the director did not cast¶
- Chat addressed to them. The chat callback asks each ruleset's
addressed(player, message), in the order they registered, after the director's cast characters and only for a public line from a player who has not opted out, whilegoanna_director_chatis notnone. A ruleset claims the line by returning{npc = "Urist", data = {...}}, with a valid character name; the director emitsnpc_addressedwith the ruleset's data plusnpc,rulesetand the line (at most 280 characters), and the line is not reported again asplayer_chat. The line stays in public chat either way, as on any server. - Speech. When
speaknames someone who is not the narrator and not a cast character, each ruleset'sspeaker(name)is asked. It returns the speaker'sname, alabel(at most 60 characters), the body'sposif it has one, andremote, the players who may hear it from afar (a set or a list). Players withingoanna_director_earshotofposreadUrist (NPC): text; players inremotebeyond it read<label> (NPC): text. The name must be a valid character name and no player's (name_taken), so a ruleset's speaker cannot pass for a player, and the(NPC)mark stays on both lines for the same reason. The text is cleaned of escapes and newlines and capped as before, and the speaker and listener rate limits apply to it by name. The ruleset'sspeak(speaker, text, listeners)sees the line before it goes out and may refuse it (reasonrules, with itsrule, such as DorfCraft's figures check);spoken(speaker, text, delivered)is told who received it. The director's own memory is not kept for a ruleset's speakers.
Small reads¶
goanna_director.connected(): true while a director is connected and not stopped.goanna_director.opted_out(name): true or false for a player seen since the server started, nil for one who has not been. The director now keeps this for players who have left, too, so an event a ruleset emits about a player who opted out and then left is still dropped.goanna_director.pacing(name): the player's phase and intensity, or nil when they are offline or opted out.goanna_director.emit(event)returns the event's sequence number, or nil when it was dropped because it names a player who opted out.
MCP tools for rulesets¶
tools/goanna-director-mcp turns each ruleset intent and query in the hello
into a tool named <ruleset>_<name> (fortress_work_order), with the
ruleset's description and schema, and reason added as a required argument
of every intent. A game master gets all of them, --role watcher only the
queries, --role voice none. The fixed 17 tools are unchanged. A ruleset's
tools exist only once a server has said hello, which is usually after the
MCP client has listed the tools, so the service declares listChanged and
sends notifications/tools/list_changed when the set changes.
tools/goanna-director-cli calls a ruleset's tool by its own name.
Where it departs from the design¶
- Hooks take
(args, ctx), with the scope inctx, rather than(scope, args). The design's singlevalidateis split intoscope,subjects,cost,rulesandplaces, so that the director runs the opt out, budget, protection and pacing steps itself rather than trusting each ruleset to. - The design's
speakers(scope)list becamespeaker(name), which can carry a position and players to reach from afar, as DorfCraft asked, andspokenwas added so a ruleset can keep its own record of what was said. - DorfCraft asked for the remote line to read
Urist, quartermaster of Deepdelve: text. It readsUrist, quartermaster of Deepdelve (NPC): text, because without the mark a label could be any text, a player's name among them. - A ruleset's refusal arrives as reason
ruleswith the ruleset's word inrule, not as its own reason, so a model can always tell the game's rules from the director's limits. DorfCraft'snot_knownisrule. addressed(player, message)andon_stopwere not in the design; they are as DorfCraft proposed.
Not built yet¶
replaces, knowledge, value and the events hook of the design (emit
serves for events); register_condition; any scope but gm; a memory hook
so remember and memory reach a ruleset's characters;
goanna_director.npc(name) and release(name) for adopting cast
characters; goanna_director.make_item(spec); and a voice scope with
server side limits. These are DorfCraft's items 5 to 8 and remain future
work.
Open questions¶
- Transport order. Build the spike on HTTP and the seat second, as recommended above, or keep the decided order and accept the Goanna client work and a second client in the spike?
- Private channel. Is a derived channel name, with the token as the capability, acceptable, given that anyone who learns the name can listen until the token is changed?
- Hosted worlds. Should
goanna_directorbe on for every world Goanna launches, or only for single player launches, with a hosted world asking the host first? - What the game master sees. Exact positions and full inventories of every player (the operator can see them anyway), or coarser views by default?
- Public chat. Decided 2026-10-01: all public chat, never direct messages or group chats.
- Rewards. An allow list per game, a value heuristic, or the game's own numbers where they exist?
- Model cadence and cost. How often a model is called, which model for a game master and which for voices, and who pays.
- Kythen. Is Kythen willing to add controllers, the new command kinds, a per village priority override, institutional knowledge holders and a spirit speaker, and in which milestone?
- Memory text. Should NPC memory hold short model written facts at all, given moderation and size, or only structured facts from events?
- Seat accounting. Is a hidden seat that games still count as a player acceptable, or should the seat transport wait for a Luanti feature that does not exist, rather than a workaround in Goanna?
- First faction experiment. Kythen lacks A10 diplomacy and institutional knowledge. Run the first multi-model experiment on Kythen anyway, or on a simpler game with teams of players as factions?