Native modules
A native module is a shared library - .dll on Windows, .so elsewhere - that the server loads
from modules/ next to its executable, before it scans resources/. It receives a pointer to
NodeApi, a struct of function pointers that is the whole server API, and it runs inside the
server process. Everything a Lua resource can do, a module can do too, plus three things only
native code can: teach the server a new resource language (a language host), run a relay
filter inline on the network threads, and own threads and sockets of its own. Write one for a
hot path or for those three; for game rules, a resource in Lua is shorter and reloadable.
The SDK is one self-contained C header, sdk/node.h - no import library, no server internals -
so any C or C++ toolchain works. This page is the module author’s guide; every function pointer is
documented in the C ABI reference.
The three exports
Section titled “The three exports”The loader looks up exactly three symbols by name (GetProcAddress on Windows, dlsym elsewhere):
NODE_EXPORT uint32_t node_plugin_abi(void); /* the SDK ABI you compiled against */NODE_EXPORT int node_plugin_init(const NodeApi* api); /* 0 = loaded, nonzero = refuse */NODE_EXPORT void node_plugin_shutdown(void);NODE_PLUGIN_ABI(), placed once at file scope, defines the first one for you. node_plugin_init
is where you register everything: it runs during startup, before any resource has loaded, and the
NodeApi pointer it receives stays valid until node_plugin_shutdown has returned. In C++ the
three definitions go inside extern "C" { }, as examples/plugin-example/plugin.cpp does.
The smallest complete module greets each joining player:
#include "node.h"
static const NodeApi* api;
static void on_join(int player_id, void* user) { char name[128]; if (api->get_player_name(player_id, name, sizeof name) < 0) { return; } api->emit_client(player_id, "hello:greet", name); api->log_info("greeted a player");}
NODE_PLUGIN_ABI()
NODE_EXPORT int node_plugin_init(const NodeApi* a) { api = a; if (api->struct_size < sizeof(NodeApi)) { api->log_error("hello_module: this server is older than the SDK it was built against"); return -1; } api->register_builtin_event("playerJoined", on_join, NULL); api->log_info("hello_module loaded"); return 0;}
NODE_EXPORT void node_plugin_shutdown(void) { api = NULL;}The console confirms a loaded module under the Module tag with hello_module.dll initialized
(libhello_module.so on Linux). on_join runs on the framework worker thread; hello:greet
arrives at the player’s client files as an ordinary wire event.
ABI negotiation
Section titled “ABI negotiation”NodeApi is positional: a module reads each capability at an offset fixed when it was compiled.
Against a reordered struct it would not fail, it would call whatever now sits at that offset - so
the layout is a contract, with a version. The header carries NODE_ABI_VERSION_MAJOR 1 and
NODE_ABI_VERSION_MINOR 12, packed into NODE_ABI_VERSION as major << 16 | minor.
- The loader calls
node_plugin_abi()first, beforenode_plugin_initand before touching any field. A module whose major differs is refused:module 'x.dll' was built against SDK ABI 2.0, this server speaks 1.12 -- refusing to load it. Rebuild the module.A module without the symbol at all is refused too:module 'x.dll' does not export node_plugin_abi -- it was built against a pre-versioning SDK. Rebuild it against the current sdk/node.h. - A module built against a newer minor loads with a warning
(
was built against a NEWER SDK (1.13 vs 1.12); it may expect capabilities this server does not have). A module built against an older minor loads silently: everything it knows about is where it expects it. - New capabilities are appended and bump the minor. Nothing is reordered or removed; a retired call becomes a stub that keeps its slot. Any change that moves a field bumps the major.
- The first two members are plain fields for your own check:
abi_version(index 0) isNODE_ABI_VERSIONas the server was built,struct_size(index 1) issizeof(NodeApi)as the server built it. Whenstruct_sizeis smaller than yoursizeof(NodeApi), the server predates some capability you compiled against, and you must not read past it - refuse to load, as the example above does, or degrade. Thedimensionsmodule refuses when the visibility-group calls it needs are missing (dimensions: this server has no visibility groups (needs ABI 1.7)).
A module that returns nonzero from node_plugin_init is unloaded with
module 'x.dll' failed to initialize (returned -1), skipping; the server starts without it.
Building plugin-example
Section titled “Building plugin-example”examples/plugin-example is the reference module: a tour of the whole NodeApi surface - a
coloured console tag, a log sink that suppresses marked lines, builtin and cancellable
events, timers, client events (cpp_ping is answered with cpp_pong), storage, HTTP, the module
channel, a relay filter, and a background thread with its own UDP socket. Its CMakeLists.txt is
the minimal project to copy: CMake 3.16, C++17, one add_library(example_plugin SHARED plugin.cpp),
the include path ../../sdk for node.h, a static CRT on Windows so the DLL has no runtime
dependency (CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>"), and ws2_32
for its socket. Its project(node-example-plugin CXX) enables C++ only; a C listing needs
project(... C CXX) (or a .cpp file name).
Linux
cd examples/plugin-examplecmake -S . -B buildcmake --build build --config Releasecp build/libexample_plugin.so /opt/nodemp/modules/Windows (PowerShell)
Set-Location examples\plugin-examplecmake -S . -B buildcmake --build build --config ReleaseCopy-Item build\Release\example_plugin.dll C:\NodeMP\modules\The output is example_plugin.dll or libexample_plugin.so. Create modules/ next to the
executable if it is not there yet: the server skips a missing folder with a debug-level line and
loads nothing. examples/build.py does the same for every module and resource in the examples
folder: python examples/build.py --out dist/server builds each folder with a CMakeLists.txt
into dist/server/modules/ and copies each folder with a resource.toml into
dist/server/resources/; --only plugin-example limits it to one. Under Docker, modules/ is
inside the image, not on the /data volume (Resources and content).
Callbacks and threads
Section titled “Callbacks and threads”Two rules cover most of the API, and three exceptions cover the rest.
| Code | Runs on | Rule |
|---|---|---|
Any NodeApi function you call |
the calling thread, inline | Thread-safe from any thread, including threads the module spawns; queries snapshot state under internal locks, emits and kicks write to the network directly. |
Registered callbacks: client events, builtin events, verdicts, vehicle notifications, timers, done of a job, HTTP responses, bus messages, module-channel data |
the single framework worker thread, one at a time | Never two at once, and never concurrent with a Lua handler. Blocking here stalls every resource: keep callbacks short and hand long work to submit_job or your own thread. |
A language host’s load and unload |
the main thread at start-up (before the worker exists); the worker for a reload; the thread stopping the server at shutdown (after the worker was joined) | The three paths never overlap a worker callback. unregister_language_host is the exception: it calls unload inline on the calling thread without stopping the worker. |
set_log_sink |
whatever thread produced the log line, under the log-hook lock | Fast, never blocking; lines you log from inside the sink bypass it. Return nonzero to suppress the line from the console and logs/server.log. |
register_relay_filter |
the network thread relaying the packet - the UDP loop, a client’s TCP thread, or the worker | A pure function of module data. Never call a state-mutating function (seat_player, kick_player, spawn_vehicle, …) from it; it can run under internal locks. Verdicts are cached per (from, to, category, subtype, vehicle) - call invalidate_relay_cache when the data the filter reads changed. |
work of submit_job |
a background pool thread | Must not call into Lua; every NodeApi function is safe. Its return value is handed to done on the worker. |
Callbacks carry no global lock, so state shared between the worker, the sink and your threads
needs atomics or a mutex of your own - plugin-example counts sink lines in a std::atomic_int
and keeps worker-only detail in plain globals. A verdict callback runs while the requesting
client’s network thread waits for the answer; keep it fast. Timers never fire after the framework
has shut down, and the framework clears the log sink and stops the worker before it calls
node_plugin_shutdown, so by then no callback of yours is running - join your own threads there.
The inline relay filter is the reason dimensions was once a module with a filter; today the
core’s visibility groups (set_player_group, set_vehicle_group) answer the same question by
comparing two numbers on the thread that holds the packet, and dimensions only decides who
gets which number. Prefer groups for room-style rules; a filter is for rules a number cannot
express.
Buffers and return conventions
Section titled “Buffers and return conventions”- An action returns
0on success and-1on failure; a question returns1for yes and0for no. Both areint- read the entry you are calling. - A function that fills a buffer
(char* buf, int buflen)returns the bytes written, or-1when the buffer is too small. - Ask for the size first: call with
buf = NULL(orbuflen = 0) and it returns the length it would write, without the NUL, so allocatelength + 1.-1from a size query means the thing does not exist. Do this for anything that can grow - the world snapshot, the metrics JSON, a vehicle config - and allocate more than reported, ready to retry: the answer is a snapshot of a live server and can be longer by the time you read it.plugin-example’sFetchStringis that loop. - Strings are UTF-8 and NUL-terminated;
datapointers passed to a callback are valid only for the duration of the call - copy what you keep. - Where a JSON view exists beside a struct getter -
get_vehicle_jsonnext toget_vehicle_info,get_player_session_jsonfor the whole session - the JSON carries the variable-length parts (passengers, whitelist, tags) that a fixed struct cannot.
Language hosts
Section titled “Language hosts”A language host teaches the server a new kind of resource. Put type = "js" in a resource.toml
and the folder is handed to whichever module registered that name; Lua is built in and needs no
host. The server never learns the language: the host embeds the interpreter, the server only
decides who gets the folder and forwards events.
NodeLanguageHost host = { 0 };host.struct_size = sizeof(NodeLanguageHost);host.type_name = "js";host.load = js_load; /* int (const char* name, const char* dir, const char* entry, void* user) */host.unload = js_unload; /* void (const char* name, void* user) */if (api->register_language_host(&host) != 0) { return -1;}Register in node_plugin_init: modules load before resources, so the host is in place when the
scan begins. load is called once per resource folder with the absolute dir and the manifest’s
server.main (already checked to stay inside the folder); return 0 to accept, anything else to
refuse - the server then logs <name> · the 'js' host refused it (returned -1) and leaves the
resource unloaded. unload runs at shutdown and on reload_resource for that name. Which thread
calls them depends on the moment: the start-up load of every resource runs on the main thread,
before the worker is started; a reload runs unload and then load on the worker, in sequence
with the dispatches; at shutdown unload runs on the thread stopping the server, after the
worker has been joined, in reverse load order. No worker callback runs concurrently with any of
the three. Client files are packaged for a hosted resource exactly as for a Lua one: what the
client runs is the game’s Lua whatever language wrote the server half.
register_language_host returns -1 for a struct_size that does not match the server’s
(register_language_host: struct size mismatch ...; rebuild the module) or a type already taken,
including the built-in lua.
Registrations a host makes while the framework is calling its load() - event handlers, timers,
relay filters, bus subscriptions, module channels, the log sink - are attributed to that resource
and dropped when it unloads. For registrations made later, from a callback or your own thread, say
whom they belong to with set_resource_owner(name) and set_resource_owner(NULL) afterwards; it
applies per thread until changed. Work already in flight is not covered: a submit_job or
http_request completion fires whether or not the resource that started it still exists.
One worker, whatever the host. Every callback the framework delivers to a hosted resource -
events, verdicts, timers, job and HTTP completions, bus messages, module-channel data - arrives on
the single framework worker thread, one at a time, exactly as for a Lua resource.
NodeLanguageHost has no field to ask for anything else (a struct of another size is refused),
and there is no call that makes the server dispatch to a host from several threads; a host whose
runtime is asynchronous hands each callback over itself - examples/js-host queues it and wakes
Node’s event loop, and waits for the JavaScript side only where the framework needs an answer:
load, unload, a …Request verdict and the relay filter (with a timeout that allows).
What a thread-safe host can run in parallel today is everything that is not a delivery: NodeApi
calls from any thread, a register_relay_filter callback (inline on the network threads), the
work function of submit_job, its own threads. Parallel delivery as an opt-in is not implemented
and not scheduled. If it were added, it could cover the fire-and-forget deliveries only: a
…Request verdict is computed while the requesting client’s network thread waits and the request
after it depends on the answer; the coalesced stream events promise one pending dispatch per
vehicle; load and unload rely on no callback running meanwhile. Those would stay sequential by
contract.
examples/js-host is the reference host: a module that claims type = "js" and runs resources on
Node. It starts the runtime on demand at the first load, so a server without JavaScript
resources pays nothing; it needs a built Node source tree (cmake -DNODE_SRC=<path>, containing
out/Release/libnode.lib) and, unlike the other modules, links the dynamic CRT because it shares
an allocator with libnode.dll. Once it is in modules/, the console prints
js-host: resources with type = "js" will run on Node, and a resource such as session-report
loads from its manifest:
name = "session-report"version = "1.0.0"type = "js"
[server]main = "server/main.js"Without the host, the same folder is skipped with
session-report · resource type 'js' has no language host loaded, skipping (a native module in modules/ must register one).
Loading C Lua modules with require
Section titled “Loading C Lua modules with require”A resource cannot require a C Lua module (probe.dll, probe.so, a library with a luaopen_*
entry): its Lua state has no C loaders. package.cpath is empty, package.loadlib is removed and
the two C searchers are gone, so require("probe") fails with module 'probe' not found, listing
only package.preload and the .lua paths it tried. require of a pure-Lua file is unchanged.
The same holds for the scratch states of node.job and node.await.
The reason is the build: the server links Lua 5.4.8 statically and exports no lua_* symbols. A
C module that imports them from the host fails to load; one that links its own Lua - the only way
to build against a static Lua - would run a second Lua runtime against the host’s lua_State,
which works for a trivial table and is undefined for anything that touches the GC, the string table
or luaL_error. Rather than let that appear to work, the loaders are off.
Native code takes the route this page describes: a module in modules/ against the C ABI, or a
language host that embeds its own runtime. For a database, node.pg is built in - see
Database access.
Events, channels and the bus from C
Section titled “Events, channels and the bus from C”The same four event kinds a resource sees, by registration function:
register_client_event(name, cb, user)- a wire event from a client;cb(player_id, data). Answer withemit_client(id, event, data),emit_all(event, data)oremit_others(except_id, event, data); the_bytesvariants (emit_client_bytes,emit_all_bytes) carry payloads with NUL bytes.register_builtin_event(name, cb, user)- the observer form of engine events (playerJoined,vehicleSpawned,serverTick, …);cb(id)carries a player id or, for vehicle events, the vehicle’s global id. Cancellable names can be observed here too, without a veto.register_vehicle_event(name, cb, user)-vehicleEdited,vehicleReset,vehiclePainted,playerSeatChangedwith(player_id, global_id, data).register_cancellable_event(name, cb, user)- a verdict callback for the nine…Requestnames; return nonzero to deny and write a reason into the buffer you are given. Every handler runs; one veto denies.vehicleNodeGrabRequestis fail-closed: registering a handler that returns0is how a module opts the grabber in.- Event names cross the C ABI as strings, and the spellings servers before 1.2.0 used
(
"playerJoin","onPlayerConnectRequest","onVehicleSpawnRequest", …) are accepted by everyregister_*_event/unregister_*_evententry as deprecated aliases: the server maps them to the canonical name and logs one warning per module per old name, for the life of the server process ([deprecated] event "playerJoin" is now "playerJoined" (module my_module.dll)- the module is the one whosenode_plugin_initis running). A module built against an older SDK keeps working; rebuild with the new names when you next touch it - the aliases go away in 2.0. See Events → Naming. register_module_channel(channel, cb, user)andsend_module(player_id, channel, data, len)- the binary channel;player_id-1broadcasts to every synced client, through the relay filter.register_resource_event(name, cb, user)andemit_resource_event(name, data)- the bus shared with Lua resources; a module appears as the source"native".
Every register_* has an unregister_* twin that takes the same cb and user; a dispatch
already in flight may still deliver one final call. Timers are set_timeout, set_interval and
clear_timer; storage is storage_set/storage_get/storage_delete with a store name of your
own (NULL means the calling resource’s store and is refused from a thread with no resource in
scope); http_request and submit_job are the asynchronous pair whose callbacks land on the
worker. The events reference names every event with its C registration.
- C ABI reference - every
NodeApientry with its index, arguments and threading note. - Events - the four kinds, from the resource side.
- Concurrency - the worker thread the callbacks share with Lua.
- Wire protocol - the frames the relay filter is asked about.
