Skip to content

Latest commit

 

History

6 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

godot_hook

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.

Concurrency model

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.

Why a socket and not WebSocket

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).

Writing hooks

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 null

Calling gamend

Context.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.gen

from 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> --import

Rejecting

Return 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})   # structured

Returning 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.

Naming

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/2

The 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.

Two-way traffic

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.

Two things that will surprise you

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. 42 comes back 42.0JSON.parse_string has no integer type. Use int() when it matters.

Reach and restriction

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]}}

What a Godot hook costs

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

Only forwarded hooks cost anything

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.

Where the remaining time goes

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.

Two hazards worth knowing

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.

Whether the gap matters

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.

Configuration

Environment variables (recommended):

  • GODOT_BIN – path to the Godot executable
  • GODOT_PROJECT_PATH – path to the Godot project directory
  • GODOT_TRANSPORTuds (default) or tcp. StreamPeerUDS is Unix-only, so Windows needs tcp
  • GODOT_SOCKET_PATH – UNIX socket path (default: /tmp/godot_hook.sock)
  • GODOT_TCP_PORT – loopback port when transport is tcp (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.

Build

From repo root:

mix deps.get
mix compile

To create a bundle directory you can drop into modules/plugins/:

mix plugin.bundle

Godot project setup

Copy 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> --import

That 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.gen

Wire format

Nothing 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.

About

Hook plugin that starts a pool of headless Godot workers and exchanges Gamend hooks with them over a UNIX domain socket, in both directions.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages