Hook plugin that starts a pool of headless Godot workers and exchanges Gamend hooks with them over a UNIX domain socket, in both directions.
+--------------------+
| Gamend |
+--------------------+
|
| hook payloads, {:packet, 4} + JSON
v
+----------------------------------------+
| godot_hook plugin |
| SocketPool (listens on a UNIX socket) |
| ProcessPool (keeps N workers alive) |
+----------------------------------------+
|
| N connections, one per worker
v
+--------------------+ +--------------------+
| Godot headless #1 | | Godot headless #N |
| blocking read on a | | blocking read on a |
| dedicated Thread | | dedicated Thread |
+--------------------+ +--------------------+
This plugin is an OTP application (:godot_hook). It starts GodotHook.SocketPool
(which listens) and then GodotHook.ProcessPool (which spawns the workers).
The Godot side lives in godot/: addons/gamend_server/gamend_client.gd owns
the connection and read loop, and hooks.gd is where your game's hooks live.
A headless Godot worker runs one engine thread, so it serves one hook at a
time. Throughput comes from running several workers, not from multiplexing one
connection. GodotHook.ProcessPool keeps GODOT_POOL_SIZE processes alive
(default: System.schedulers_online()), each connects back to the socket, and
GodotHook.SocketPool hands requests out round-robin.
Requests to one worker are serialised by a GenServer.call, which matches what
the engine can actually do. Worker selection is lock-free: it reads a
:persistent_term and bumps an :atomics counter, so the hot path never calls
into a GenServer.
Hooks must be stateless across calls — any call can land on any worker. Keep state in Gamend (KV, Postgres) and keep GDScript pure.
The WebSocket client this replaced polled from _process, so every round trip
waited for the next frame: about 8 ms at 60 fps. That, not framing, was the
cost. Measured locally, 256-byte echo, concurrency 1:
| transport | p50 | rps |
|---|---|---|
| UDS, blocking read on a Thread | 13.3 us | 67,361 |
| in-process NIF (libgodot) | 18.5 us | 43,171 |
| TCP loopback + no_delay, blocking Thread | 28.3 us | 27,603 |
WebSocket polled in _process |
6894 us | 144 |
Moving the read onto a Thread doing blocking get_data() is where the ~250x
comes from; picking UDS over TCP is only the last 2x.
The in-process NIF is listed for completeness. It is not faster, and it caps
the node at one engine (only_one_instance), where separate processes scale:
6,628 rps at one worker to 28,535 at eight, on 8 cores. A GDScript crash also
takes the whole BEAM node with it in-process.
Cost per worker: ~114 MB RSS, ~167 ms to boot. Size the pool with
GODOT_POOL_SIZE (default: scheduler count).
Your hooks script inherits nothing. Define the hooks you want as plain methods; GamendClient finds them by name, and hooks you did not write are skipped before the socket and cost nothing. That is the same rule a transpiled GDScript plugin follows, where a hook you did not write simply is not a function.
extends Node
func before_lobby_create(attrs):
if attrs.get("name", "") == "banned":
return GamendError.new("name_not_allowed")
# The whole Gamend SDK, one class per context.
# Tuples arrive as Arrays, so {:ok, entry} is ["ok", {...}].
var res = KV.get("lobby_defaults")
if res != null and res[0] == "ok":
attrs["motd"] = res[1]["value"].get("motd", "")
attrs["blessed_by_godot"] = true
return attrs
func before_purchase(_user, product):
if product.get("price_cents", 0) > 100_00:
return GamendError.new("too_expensive")
return null # no opinion; Gamend's default applies
func after_user_register(user):
Economy.grant(user.get("id", ""), "gold", 100, {"reason": "welcome"})
return nullContext.function(...) — Economy.grant(...), KV.get(...),
Accounts.update_user(...) — the same spelling an Elixir plugin uses, and the
same spelling a transpiled .gd plugin uses. 518 methods across 21
contexts, covering every callable SDK function, so a GDScript hook reaches as
far as an Elixir one.
They are generated, along with the list of hook names the client reports on connect:
mix godot.hooks.genfrom GodotHook.Api.signatures/0 and
Gamend.Modules.GodotHook.forwarded_hooks/0, so neither can drift from the
Elixir side. Re-run after changing either.
class_name needs the class cache, which a headless run does not build on
its own. Run this once at image build time, or KV, Economy and the rest
will not resolve:
godot --headless --path <project> --importReturn a GamendError to reject whatever the hook guards. Gamend turns it into
{:error, reason}:
return GamendError.new("too_expensive")
return GamendError.new({"code": "too_expensive", "limit": 10000}) # structuredReturning null means "no opinion" and lets Gamend's default apply. A rewrite
hook returns the new attrs Dictionary instead.
It is a type rather than a magic string on purpose. The previous convention was
return "error:too_expensive", matched by prefix — so "Error:" or
"error :" silently allowed the thing you meant to block. There is no
spelling of GamendError.new(...) that fails that way. The old form is still
recognised, but pushes an error telling you to migrate; it will be dropped.
Contexts keep the SDK's own capitalisation (Gamend.KV is KV) and functions
keep their own names. The exception is ? and !, which GDScript identifiers
cannot contain: Accounts.has_password? is simply Accounts.has_password.
Where stripping the mark collides a pair — get_user and get_user! — the two
merge into one name, each keeping its own arity. Nothing is lost:
Leaderboards.get_record(record_id) # leaderboards.get_record!/1
Leaderboards.get_record(leaderboard_id, user) # leaderboards.get_record/2The generated doc comment says which arity reaches which function, since
overloading silently would be a trap. Where both twins want the same arity —
get_user/1 and get_user!/1, the same lookup either way — the non-raising one
wins, because it answers null where the other raises, and null is what a
GDScript caller can branch on.
Nothing raises through to a script. Every call is wrapped on the Elixir
side, so even a ! function answers null with the reason in
GamendClient.last_error rather than taking the worker down. 518 names cover
569 name/arity pairs — the whole callable SDK.
Parameters named after a GDScript keyword get a p_ prefix. Functions taking a
pid, changeset or anonymous function are not generated — they cannot cross a
JSON wire.
The socket carries four frame kinds, so a script both serves hooks and calls back into Gamend on the same connection:
Gamend -> Godot {hook, args, meta, request_id} hook call
Godot -> Gamend {request_id, ok, result|error} its reply
Godot -> Gamend {call, args, request_id} callback into Gamend
Gamend -> Godot {request_id, ok, result|error} its reply
The generated SDK methods block for a result and must only be used from inside
a hook, because only the read thread may read the socket.
GamendClient.notify() only writes, is mutex-guarded, and is safe from
anywhere — use it for fire-and-forget reports where the result does not
matter.
Blocking is safe on the Gamend side — callbacks are served asynchronously, so the worker is never waiting on the script that is waiting on it. It still holds that worker, so keep callbacks short.
JSON is the wire format and it is lossy in two ways worth knowing before you debug them:
- Tuples arrive as Arrays.
{:ok, %{...}}reaches GDScript as["ok", {...}], not a Dictionary. Most Gamend functions return{:ok, _}. - Integers arrive as floats.
42comes back42.0—JSON.parse_stringhas no integer type. Useint()when it matters.
A script names an entry, never a module and function, and nothing in that path
calls String.to_atom/1 — but the map is generated from the SDK, so the reach
is the SDK's. That includes destructive calls like accounts.delete_user,
because the person writing the GDScript is the person writing the plugin. Where
that is not true:
config :godot_hook, :denied_api, ["accounts.delete_user", "accounts.revoke_all_tokens"]:extra_api adds your own modules, which beats reaching for the admin HTTP API
from inside a script — same process, no token, no trip through the endpoint:
config :godot_hook, :extra_api, %{"mygame.roll_loot" => {MyGame.Loot, :roll, [:raw, :raw]}}Same hook, same trivial body, measured inside a running Gamend (SQLite, macOS, 8 cores). The Elixir column is a plugin doing the identical work in Elixir:
| hook | Elixir plugin | Godot plugin |
|---|---|---|
| overridden in GDScript, blocking | 5.0 us | 48.8 us |
overridden, blocking + a KV.get back into Gamend |
4.7 us | ~115 us |
| overridden, fire-and-forget | 4.3 us | 14.9 us |
| not overridden — skipped before the socket | 4.7 us | 5.1 us |
A worker reports which hooks its script actually overrides in the hello frame, and Gamend skips the socket entirely for the rest. Most games override a handful of the 73, so this is the difference between paying 48 us on every hook and paying it only where a script has an opinion — 65 us to 5 us for the ones nobody overrode, which is Elixir-plugin speed.
An empty or missing report means unknown, and everything is forwarded. It must never mean "overrides nothing": a script that failed to report would otherwise have every hook silently skipped, including the ones that reject.
Interleaved sampling, one worker, 2500 samples per stage:
| stage | cost |
|---|---|
Elixir payload prep (json_safe + Jason.encode!) |
2.1 us |
| the socket itself | ~13 us |
GenServer hops + GDScript JSON.parse_string / JSON.stringify |
~22 us |
Gamend's own Task.async hook dispatch |
~9 us |
The socket is not the problem — it already went from 6.9 ms (the old polled
WebSocket) to ~13 us. What is left is JSON on the GDScript side plus process
hops. Anyone wanting to close the gap further should start with the GDScript
codec (var_to_bytes instead of JSON), not the transport.
Callbacks must not re-enter Godot. A script blocked in an SDK call
cannot answer another hook, so a callback that triggers a forwarded hook would
stall until timeout — KV.get() reaching before_kv_get is the obvious case.
Hooks triggered from inside a callback therefore take their Elixir default; the
callback task registers its pid and forwards?/1 checks it. Use an O(1)
membership test for that check, not Process.info(pid, :dictionary) — reading
the dictionary copies it, and cost more than the hook it guarded.
notify_async has no backpressure. A burst of fire-and-forget hooks queues
ahead of blocking ones on the same worker. Thousands in a tight loop will make
the next blocking hook wait out its timeout.
48 us is about two thirds of one cached Gamend.KV.get (73 us measured), and
20,000 hooks/s is far above the ~2,000 plugin-calls/s per core Gamend sustains
on real hardware. A hook that touches the database is dominated by the database.
A hot-path hook that does nothing but arithmetic belongs in Elixir. Everything
in between is a fair trade for writing game logic in GDScript.
Environment variables (recommended):
GODOT_BIN– path to the Godot executableGODOT_PROJECT_PATH– path to the Godot project directoryGODOT_TRANSPORT–uds(default) ortcp.StreamPeerUDSis Unix-only, so Windows needstcpGODOT_SOCKET_PATH– UNIX socket path (default:/tmp/godot_hook.sock)GODOT_TCP_PORT– loopback port when transport istcp(default:4010)GODOT_POOL_SIZE– number of Godot workers (default: scheduler count)GODOT_ARGS– extra args appended to the Godot command (space-separated)
If you build via this repo's Dockerfile, Godot is installed into the image and GODOT_BIN defaults to /opt/godot/godot.
From repo root:
mix deps.get
mix compileTo create a bundle directory you can drop into modules/plugins/:
mix plugin.bundleCopy godot/addons/gamend_server/ into your project and add a hooks.gd
(extends Node) as your main scene node, with a GamendClient node as its
child — see godot/main.tscn. The client uses its parent as the hooks target,
or hooks_node_path if you put it somewhere else. Then, once, so class_name
resolves in a headless run:
godot --headless --path <project> --importThat builds .godot/global_script_class_cache.cfg. Without it a headless Godot
cannot resolve KV, Economy, GamendError or the rest, and every worker
will fail to start. In a
Dockerfile, run it at build time.
Regenerate the addon whenever the Elixir forwarding tables or the SDK change:
mix godot.hooks.genNothing needs to implement this by hand — gamend_client.gd does — but for
reference, frames are length-prefixed (4-byte big-endian) JSON:
{
"hook": "before_lobby_create",
"args": [{"title": "my lobby"}],
"meta": {"caller": "..."},
"at": "2026-08-21T12:34:56Z",
"request_id": "42"
}and the reply:
{"request_id": "42", "ok": true, "result": {"title": "my lobby", "motd": "welcome"}}A frame with no request_id is a notification and must not be answered.