Configure, 5 minute read

Lua scripts, modules and plugins

Contents

Write rift.lua as an ordinary script. No outer return { ... } is needed:

local config = require("rift.config") -- Also available as rift.config.

config.listeners.public = "0.0.0.0:25565"
config.backends.lobby = "127.0.0.1:25566"
config.routes.public = "lobby"
config.limits.max_connections = 1024

require("config.services")
rift.plugin("greeting")

rift is a global, like Neovim's vim; local rift = require("rift") returns that same API. rift.config contains the existing configuration fields, with empty listeners, backends, managed_servers, service_groups, templates, routes and limits tables ready to edit. Other sections are optional; assign them before editing their fields. All existing schema, authentication, permission and reload checks still apply. Bare globals such as listeners = ... are ordinary Lua variables, not configuration fields.

Use rift.config.templates to describe local provisioning assets: server_jar, optional plugins jar paths, a configs directory and a map directory. These Minecraft server assets are separate from Rift's Lua modules and folder plugins. Use rift.config.service_groups for lifecycle commands, a port range, an optional named template, and a storage policy (persistent by default, or disposable for games whose generated directory should be deleted on removal). Disposable groups require a template. Routes can target the group to spread logins across its runtime instances. Asset paths resolve relative to the configuration file. Templates and group policies can change live when existing instances retain their process definitions and storage policy. Remove affected instances before changing those settings. Creating/removing instances also works live. Provisioning never accepts the Minecraft EULA automatically. See service groups, asset templates and the provisioning example.

rift.setup({ ... }) assigns top-level configuration fields. It replaces each supplied field, without recursively merging nested tables. You can call it more than once. Edit individual entries to extend an existing section. Do not replace the rift.config table itself.

Existing files that return a configuration table remain supported. Returned fields override fields assigned through rift.config or rift.setup; event and command registrations are then applied to the resulting configuration.

Folder layout and imports

examples/modular/ is a complete runnable example:

my-network/
├── rift.lua
├── lua/
│   └── config/
│       ├── network.lua
│       └── services.lua
└── plugins/
    ├── greeting/
    │   ├── init.lua
    │   └── lua/
    │       └── greeting/
    │           └── init.lua
    └── echo/
        └── init.lua

Paths are relative to the configuration file's directory, not the process's working directory. require("config.network") looks for lua/config/network.lua and then lua/config/network/init.lua. Modules can execute settings directly, return functions, or return tables. Modules with no return value yield true. Like Lua's require, successful imports are cached by name within each VM; a module returning false is evaluated again on the next import. A module receives its import name as ....

Use dotted names with letters, digits, _ or - in each component, up to 128 bytes. Filesystem paths, .., absolute paths, native libraries and package.path are not supported. Import errors identify the module and file; circular imports fail with an explicit diagnostic. rift, rift.config, rift.store, rift.http and rift.permissions are reserved modules.

Folder-based plugins

rift.plugin("greeting") adds plugins/greeting/lua/ to module lookup, then executes plugins/greeting/init.lua. Plugin names use letters, digits, _ or -, up to 128 bytes. The entry script receives the plugin name as ... and can use the same API as rift.lua. It needs no return value:

-- plugins/greeting/init.lua
local rift = require("rift")
local greeting = require("greeting")

rift.on("http", function(request)
    if request.path == "/ext/hello" then
        return { body = greeting.text() }
    end
end)

Plugins load explicitly in the order you call rift.plugin. Repeated calls return the cached result and do not register the plugin twice. The result is the entry script's returned value, or true when it returns nothing. A plugin may therefore return a module with its own setup function:

rift.plugin("my-plugin").setup({ greeting = "Welcome!" })

Lookup checks the configuration's lua/ first, then each loaded plugin's lua/ in import order; within each directory name.lua precedes name/init.lua. Already imported modules remain cached. Give plugin modules their own namespace (for example greeting.format) to avoid collisions. Copy a plugin folder into plugins/ to install it; Rift does not download or auto-enable plugins.

Registering callbacks and commands

rift.on(event, callback) appends a handler. Supported events are route, http, message, login, initial_server, join, disconnect, before_transfer and after_transfer. Arguments and results match the existing on_route, on_http, on_message and authenticated extension callbacks.

Handlers run in registration order. A directly configured callback runs first. For all events except message, the first non-nil result ends the chain and is validated by the host. Return nil to let later handlers run. This means observer events such as join must still return nil. Message handlers all run, and their return values are ignored. An error stops the chain and follows the existing event's failure policy; handlers share one execution budget.

rift.on("route", function(connection)
    if connection.peer_ip == "192.0.2.10" then
        return { reject = true, reason = "Access denied" }
    end
end)

-- Requires online authentication, Velocity forwarding and a permission grant.
rift.config.extensions = {
    api_version = 1,
    permissions = { ["*"] = { ["greeting.hello"] = true } },
}
rift.command("hello", {
    permission = "greeting.hello",
    run = function(ctx) return { message = "Hello " .. ctx.name } end,
})

Authenticated event and command registration creates extensions and defaults its api_version to 1 when absent. Explicit versions are still validated. rift.command(name, definition) uses the existing command schema and permission checks. Duplicate names, including collisions with extensions.commands, fail validation. Settings and registrations belong in initialization, outside runtime callbacks. rift.on, rift.command and rift.setup reject late registration.

rift.publish(subject, payload, reply?) and rift.enabled remain available in callbacks. Imports and top-level initialization cannot publish messages. Captured API references, including local publish = rift.publish, work in callbacks.

Reloads and execution limits

Startup, validation and reload capture the entry script and all .lua files under neighboring lua/ and plugins/ directories. Only explicitly imported modules and plugins execute. Hidden entries such as .git are skipped; symlinks in these trees are rejected. Keep large assets and unrelated files elsewhere. The combined source limit is 256 KiB, with up to 256 module/plugin files, 1024 directory entries and 16 directory levels. Files must contain UTF-8 Lua source; bytecode is not accepted.

Each callback builds a fresh VM from that immutable snapshot. Even a require inside a callback uses the saved file contents; it never reads live files. Modules, globals and upvalues do not persist between callbacks. A successful reload captures edited modules and plugins even when rift.lua did not change. Existing player sessions retain their original code. API v1 also pins permissions; API v2 supports live grants, durable state, jobs and HTTP integrations as described in the extension API. A failed reload preserves the active configuration.

All imports and handlers share the existing 8 MiB VM memory limit and 100,000 instruction budget. Configuration evaluation during startup, checks, reloads and web-editor validation/saves has a five-second wall deadline, allowing for cold VM setup and scheduling delays on busy hosts. Runtime callbacks retain their 50 ms execution deadline (v2 scheduled jobs have a five-second wall deadline). Direct filesystem/process I/O, native modules, arbitrary code loaders and the other restricted facilities remain unavailable. This is a local Lua plugin API, not the full Neovim runtime.

rift check path/to/rift.lua, reloads, and web-editor validation/saves resolve the same local module directories. The web editor edits the entry script; edit module/plugin files on disk and reload to apply them. Rust embedders can use Config::load(path) or Config::from_lua_at(source, path) for local imports; Config::from_lua(source, name) has no filesystem access and only exposes the built-in rift modules.