Knight docs
EN ID
GitHub

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

CapabilityHow
Share functionsexport function name(...) in the module, call name(...) after import "module";
Add commandsplayer::command("/name") { ... };
React to eventson player::spawn() { ... }; or the classic player::spawn() { ... };
Block an eventreturn false; from a boolean handler
Keep stateTop-level let / const in the module
Run timerstimer::every(60s) { ... };
Note
Modules are statically type-checked together with the gamemode, so a typo in an imported module is reported at startup, not at runtime.

2. Module lifecycle

StepWhat happens
Loadimport "x"; is resolved, the file is inlined once and parsed
CheckEvery function in the program is registered, then all files are type-checked
InitTop-level let/const run, then system::start fires
RuntimeExported functions and on hooks are callable
Stopsystem::stop fires; module state goes away with the server

3. Writing a module

knight
// 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:

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

knight
// 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 orderPath
1Gamemodes/<name>.sa
2Gamemodes/modules/<name>.sa
3modules/<name>.sa (one level above the gamemode)
4modules/<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.

go
// 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 APIPurpose
Register(fn)Declare the plugin in init()
InfoName/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, BoolSmall value helpers for native arguments
Warning
A Go plugin runs inside the server process with full privileges. Only build with plugins you trust. Script modules cannot crash the server: their errors are logged and the event continues.

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.

knight
system::config() {
    server.name = "Euporia RP";
    server.plugins = ["plugins", "mods/anticheat.sa"];
};
PathWhat 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

text
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:

go
package main

import (
    _ "knight/plugins/json"
    _ "knight/plugins/anticheat"
)
Note
Naming the folder is what activates the plugin: if the folder is not listed, the compiled-in plugin stays dormant even though its code is present. A folder with 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.

knight
// 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");
};
Tip
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:

go
// 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 logMeaning
plugin loaded: welcome 1.0.0Init succeeded
plugin name: ... errorInit returned an error; the server refuses to start
native X overrides an earlier pluginTwo plugins registered the same name; the last one wins

9. Which one should I use?

NeedUse
Gameplay rules, jobs, economy, anticheatScript module (.sa)
Admin commands and chat policyScript module (.sa)
Database schema and persistenceScript module with SQLite/MySQL
HTTP callbacks, Discord bot, external APIGo plugin
A brand new primitive (socket, file watcher)Go plugin
Hot-patching gameplay without restartScript module: edit and restart the gamemode

10. Common mistakes

SymptomCause
module not found: xFile is not in any search path, or the extension is missing
circular import: a -> b -> aTwo modules import each other
expected ';' after event blockon ... { } or ns::name(...) { } without the trailing ;
Name clash with a nativePick another function name; natives are reserved
Chat filter does nothingThe handler returns true instead of false
State resets unexpectedlyModule state lives in memory; persist with SQLite/MySQL