Plugin System
How Knight extends a server: script modules today, Go plugins for heavy features.
Knight has one extension point with two kinds of extensions. Script modules (.sa) are what most server owners use: no Go knowledge, no rebuild, drop a file and import it. Go plugins are for the server author who needs native-speed features such as a database driver, HTTP client or a custom native. Both feed the same event and function registries, so a module can call a Go plugin and a plugin can listen to the same events.
1. What a module can do
| Capability | How |
|---|---|
| Share functions | export function name(...) in the module, call name(...) after import "module"; |
| Add commands | player::command("/name") { ... }; |
| React to events | on player::spawn() { ... }; or the classic player::spawn() { ... }; |
| Block an event | return false; from a boolean handler |
| Keep state | Top-level let / const in the module |
| Run timers | timer::every(60s) { ... }; |
2. Module lifecycle
| Step | What happens |
|---|---|
| Load | import "x"; is resolved, the file is inlined once and parsed |
| Check | Every function in the program is registered, then all files are type-checked |
| Init | Top-level let/const run, then system::start fires |
| Runtime | Exported functions and on hooks are callable |
| Stop | system::stop fires; module state goes away with the server |
3. Writing a module
// Gamemodes/modules/jobs.sa
module "jobs" {
version: "1.0.0";
author: "Euporia";
};
const JOB_TRUCKER = 1;
const PAYMENT = 250;
let active = {};
export function startJob(int id, int job) {
if (at(active, id) != null) { return false; }
active[id] = job;
SendClientMessage(id, "Pekerjaan dimulai.");
return true;
}
export function finishJob(int id) {
let job = at(active, id);
if (job == null) { return 0; }
del(active, id);
GivePlayerMoney(id, PAYMENT);
return PAYMENT;
}
player::command("/job", int job) {
if (!startJob(playerid, job)) {
SendClientMessage(playerid, "Kamu sudah punya pekerjaan.");
}
};
Use it from the gamemode:
import "jobs";
system::start() {
AddPlayerClass(0, 1958.4, 1343.2, 15.4, 270.0, 24, 50);
};
player::command("/selesai") {
let paid = finishJob(playerid);
SendClientMessage(playerid, paid > 0 ? "Dibayar {paid}" : "Tidak ada pekerjaan.");
};
4. Hooks and veto
A boolean event handler (such as player::chat) can veto. Handlers run in load order; the first return false stops the chain and skips the engine default (for chat: no relay). This lets a module enforce policy without editing the main gamemode.
// Gamemodes/modules/mute.sa
module "mute" { version: "1.0.0"; };
let muted = {};
export function mute(int id) { muted[id] = true; }
export function unmute(int id) { del(muted, id); }
on player::chat(string message) {
if (has(muted, playerid)) {
SendClientMessage(playerid, "Kamu dibisukan.");
return false; // block the line
}
return true; // let it through
};
5. Where to put modules
| Search order | Path |
|---|---|
| 1 | Gamemodes/<name>.sa |
| 2 | Gamemodes/modules/<name>.sa |
| 3 | modules/<name>.sa (one level above the gamemode) |
| 4 | modules/<name>.sa in the working directory |
A module is loaded once even if several files import it. Circular imports and missing files are startup errors, reported with the full chain.
6. Go plugins (server authors)
When a feature needs to touch sockets, files or a driver, write it in Go and register it. Plugins are compiled into the server, so there is no ABI risk, no GC boundary and -race still applies. Users install the resulting binary; they never see Go code.
// plugins/discord/discord.go (package knight/plugins/discord)
package discord
import "knight/pkg/plugin"
type discord struct{ api plugin.API }
func init() { plugin.Register(func() plugin.Plugin { return &discord{} }) }
func (d *discord) Info() plugin.Info {
return plugin.Info{Name: "discord", Version: "1.0.0"}
}
func (d *discord) Init(api plugin.API) error {
d.api = api
api.RegisterNative("DiscordSend", d.send) // .sa: DiscordSend(...)
api.Hook("player::connect", plugin.PriorityEarly, d.onConnect)
return nil
}
func (d *discord) send(ctx plugin.Ctx, args []plugin.Value) plugin.Value {
// args[0] is the first script argument.
d.api.Log("DiscordSend(%s)", args[0].Str)
return plugin.IntV(1)
}
func (d *discord) onConnect(ctx plugin.Ctx) bool {
ctx.Log("player %d connected", ctx.Int(0))
return true // false vetoes the event
}
func (d *discord) Shutdown() {}
| Plugin API | Purpose |
|---|---|
Register(fn) | Declare the plugin in init() |
Info | Name/version shown in the startup log |
RegisterNative(name, fn) | Expose a function to .sa gamemodes |
Hook(event, priority, fn) | Observe or veto an event alongside modules |
Log, Int, String, Bool | Small value helpers for native arguments |
Loading plugins from a path
Instead of blank imports, plugins can be declared from the gamemode. server.plugins takes a list of paths. A path is either a folder or a single .sa file.
system::config() {
server.name = "Euporia RP";
server.plugins = ["plugins", "mods/anticheat.sa"];
};
| Path | What loads |
|---|---|
plugins (folder) | Every *.sa file inside and every immediate subfolder containing plugin.go |
plugins/json (folder) | That one Go plugin package |
plugins/extra.sa (file) | That one script plugin |
Folder layout
plugins/
├── json/ <- Go plugin (plugin.go, compiled into the server)
│ ├── plugin.go
│ └── README.md
├── anticheat/ <- Go plugin
│ └── plugin.go
└── welcome.sa <- script plugin (no rebuild needed)
Everything in the folder is loaded together, sorted by name. A folder without plugin.go is ignored; a .sa file is always loaded as a script plugin.
Go plugins must be compiled in
A Go plugin is real code that runs inside the server process, so its package has to be part of the binary. Import it once in cmd/knight-server/plugins_enabled.go (or your own build), then point server.plugins at its folder:
package main
import (
_ "knight/plugins/json"
_ "knight/plugins/anticheat"
)
plugin.go but no matching compiled package is a startup error, so a typo never silently disables a feature.Script plugins need no rebuild
A .sa plugin behaves exactly like an imported module: its functions are callable from the gamemode and its on handlers run with the gamemode's handlers. This is the path for anything a server owner can write themselves.
// plugins/welcome.sa
module "welcome" { version: "1.0.0"; };
export function welcomeGreeting() { return "Selamat datang!"; };
on player::connect() {
SendClientMessage(playerid, welcomeGreeting());
};
player::command("/help2") {
SendClientMessage(playerid, "Ditambahkan oleh plugin .sa");
};
plugins anticheat.sa extra/ in server.cfg works too, for operators who prefer the file. Values declared in system::config() win, so one source of truth is enough.8. Activating a Go plugin
A plugin becomes part of the server through a blank import. Create a small entry point that imports the plugins you ship and runs the normal server:
// cmd/knight-server/plugins.go
package main
import (
_ "knight/plugins/discord"
_ "knight/plugins/anticheat"
)
Rebuild the server. On startup the log shows every loaded plugin, and gamemodes can call its natives immediately. Nothing changes for players: they install the binary as usual.
| Startup log | Meaning |
|---|---|
plugin loaded: welcome 1.0.0 | Init succeeded |
plugin name: ... error | Init returned an error; the server refuses to start |
native X overrides an earlier plugin | Two plugins registered the same name; the last one wins |
9. Which one should I use?
| Need | Use |
|---|---|
| Gameplay rules, jobs, economy, anticheat | Script module (.sa) |
| Admin commands and chat policy | Script module (.sa) |
| Database schema and persistence | Script module with SQLite/MySQL |
| HTTP callbacks, Discord bot, external API | Go plugin |
| A brand new primitive (socket, file watcher) | Go plugin |
| Hot-patching gameplay without restart | Script module: edit and restart the gamemode |
10. Common mistakes
| Symptom | Cause |
|---|---|
module not found: x | File is not in any search path, or the extension is missing |
circular import: a -> b -> a | Two modules import each other |
expected ';' after event block | on ... { } or ns::name(...) { } without the trailing ; |
| Name clash with a native | Pick another function name; natives are reserved |
| Chat filter does nothing | The handler returns true instead of false |
| State resets unexpectedly | Module state lives in memory; persist with SQLite/MySQL |