Coming from Java? (Velocity)
How what you do in a Velocity plugin maps to a Lua plugin on Basalt Proxy, side by side.
This page is for people who have written Java plugins for Velocity. If you have not written plugins before, start with Introduction to Lua and Plugins instead.
If you have, you already know what a proxy plugin does: watch players come and go, move them between servers, add commands and keep a little data. This page shows how each of those is written in Lua on Basalt Proxy. Lua itself is covered in Introduction to Lua. Coming from BungeeCord instead? See Coming from Java? (BungeeCord).
What changes#
| In a Velocity plugin | On Basalt Proxy |
|---|---|
| Java compiled to a jar with Gradle or Maven | Lua source in a folder, zipped into a .bppkg with kiln pack --target proxy |
@Plugin annotation |
plugin.toml |
A main class with @Inject |
A src/main.lua that runs when the plugin loads. Nothing to inject |
@Subscribe methods on event classes |
on("event.name", handler) |
Player objects |
Player tables with name, uuid, server, and methods called with a colon |
Adventure Component |
MiniMessage strings, the format Adventure users already know |
| Restart the proxy to test | reload in the console |
| The scheduler and executors | later, every and wait on one plugin thread |
The project#
@Plugin(id = "lobbytools", name = "Lobby Tools", version = "1.0.0", authors = {"Alex"})
public final class LobbyTools {
@Inject private ProxyServer server;
@Inject private Logger logger;
@Inject @DataDirectory private Path dataDirectory;
@Subscribe
public void onInit(ProxyInitializeEvent event) {
logger.info("Ready");
}
}# plugin.toml
id = "lobbytools"
name = "Lobby Tools"
version = "1.0.0"
authors = ["Alex"]
api = 1
main = "src/main.lua"-- src/main.lua
on("plugin.enable", function()
log.info("Ready")
end)log, players, servers, config and the rest are globals, so there is no ProxyServer to hold. The data folder is
created for you, and resources/ is copied into it on first load, see
Plugins. Plugins are off by default: the operator turns them on in
config/plugins.toml. Dependencies go in [depends] and [soft-depends] of the manifest, like dependencies in
@Plugin.
Events#
| Velocity event | Lua event |
|---|---|
ProxyInitializeEvent |
plugin.enable |
ProxyShutdownEvent |
plugin.disable |
LoginEvent |
player.login |
PostLoginEvent |
player.join |
ServerPostConnectEvent |
player.server_change |
PlayerChatEvent |
player.chat |
KickedFromServerEvent |
player.kick |
DisconnectEvent |
player.quit |
ProxyPingEvent |
server.ping |
@Subscribe(order = PostOrder.LATE)
public void onSwitch(ServerPostConnectEvent event) {
RegisteredServer previous = event.getPreviousServer();
if (previous != null) {
logger.info(event.getPlayer().getUsername() + " moved from " + previous.getServerInfo().getName()
+ " to " + event.getPlayer().getCurrentServer().get().getServerInfo().getName());
}
}on("player.server_change", { priority = "late" }, function(e)
log.info(e.player_name .. " moved from " .. e.from_server .. " to " .. e.server)
end)- The handler receives one table,
e. The player ise.player, with the flat fieldse.player_name,e.player_uuidande.servernext to it. Every event is in Plugin events. - Priorities are
FIRST,EARLY,NORMAL,LATEandLAST, the same five asPostOrder. player.server_changefires when a player moves to another backend, so it plays the part ofServerPostConnectEventwith a previous server. The first backend is reported byplayer.join.- A filter is an option:
on("player.join", { server = "lobby" }, fn)replaces theifthat checks the backend. - Events are observations. Velocity lets a handler deny a login, change the server a player connects to, redirect a kick or edit the ping. In Basalt Proxy a handler cannot cancel or change any event, and nothing it returns alters what the proxy does.
player.logincarries onlyplayer_nameandplayer_uuid: the player has noserveryet and is not inplayers.online().
Players and servers#
server.getPlayer("Steve").ifPresent(p -> {
p.sendMessage(MiniMessage.miniMessage().deserialize("<green>Hello"));
server.getServer("lobby").ifPresent(s -> p.createConnectionRequest(s).fly());
});
for (Player p : server.getAllPlayers()) { ... }
int online = server.getPlayerCount();local p = players.by_name("Steve")
if p then
p:send("<green>Hello")
p:connect("lobby")
end
for _, who in ipairs(players.online()) do ... end
local online = players.count()An Optional is a value or nil. A server is its name from backends.toml: servers.list() returns the names and
servers.exists(name) checks one. player:connect(name) returns true when the move was accepted and false otherwise,
for example when the backend does not exist. A player table is a snapshot, so player.server does not follow later moves.
| Velocity | Lua |
|---|---|
player.getUsername() |
player.name |
player.getUniqueId() |
player.uuid, as text |
player.getCurrentServer() |
player.server |
player.sendMessage(component) |
player:send(text, args) |
player.disconnect(component) |
player:kick(reason) |
server.sendMessage(component) |
broadcast(text, args) |
player.createConnectionRequest(target).fly() |
player:connect(name) |
server.getAllServers() |
servers.list() |
See Plugin API.
Commands#
CommandMeta meta = server.getCommandManager().metaBuilder("coins").build();
server.getCommandManager().register(meta, (SimpleCommand) invocation -> {
String[] args = invocation.arguments();
if (args.length != 2 || !args[0].equals("top")) {
invocation.source().sendMessage(Component.text("Usage: /coins top <limit>"));
return;
}
int limit;
try {
limit = Integer.parseInt(args[1]);
} catch (NumberFormatException ex) {
invocation.source().sendMessage(Component.text("Not a number"));
return;
}
// ...
});cmd("coins top [limit: int 1..25]", function(src, limit)
src:send("Top {limit}", { limit = limit or 10 })
end, { permission = "shop.top" })The spec declares the arguments, their types and their ranges, like a Brigadier command does. The Usage: reply and the
completion that 1.13 and later clients show come from it, so the handler only receives values. The same command works
from the console, where src.is_console is true. The spec syntax is in Plugin API.
Permissions are a short list
Velocity asks a permission provider, usually a plugin. The proxy has no groups: a command with a permission can be run
by the console and by the players in operators in config/plugins.toml, and a command without one by everybody. The
permission string is a label for the plugin author, and player.hasPermission has no equivalent. Finer permissions belong
on the backend, where they are checked by the server.
Scheduler#
server.getScheduler().buildTask(this, () -> player.sendMessage(Component.text("Boom")))
.delay(2, TimeUnit.SECONDS)
.schedule();
ScheduledTask task = server.getScheduler().buildTask(this, this::announce)
.repeat(1, TimeUnit.MINUTES)
.schedule();
task.cancel();later(40, function() player:send("Boom") end)
local task = every(20 * 60, announce)
task:cancel()
-- pausing in the middle of a handler
on("player.join", function(e)
wait(40)
e.player:send("<gray>Two seconds after joining.")
end)Time is in ticks of 50 ms, 20 to a second, instead of a TimeUnit. wait pauses the handler that calls it while the
plugin thread serves everyone else, so a delay is a line of code and not a second callback. It works in handlers,
commands, timer callbacks and plugin.enable, and not at the top of the script. See
Plugin API.
Configuration and messages#
Path file = dataDirectory.resolve("config.toml");
// load it with a TOML or YAML library, fall back to defaults...
String lobby = config.getString("lobby", "lobby");# resources/config.toml
lobby = "lobby"
max-players = 100local lobby = config.lobby or "lobby"
local limit = config.max_players -- a key with a dash also works with an underscoreresources/config.toml is the config table, read once when the plugin loads. Messages go in resources/lang/en_us.toml
and are used by key:
welcome = "Welcome back, {name}. Visit number {visits}."e.player:send("@welcome", { name = e.player_name, visits = 3 })@key looks the text up and {name} fills it from the table. Text is MiniMessage, the same format you would pass to
MiniMessage.miniMessage().deserialize.
Saving data#
| Velocity | Lua |
|---|---|
| A map saved to a file on shutdown | store.set(key, value), saved at once |
| A database table keyed by UUID | player.data.visits = 5, per player and plugin |
| A cache that must not survive | A local variable at the top of the file |
# plugin.toml
[capabilities]
storage = true
players_data = trueon("player.join", function(e)
local d = e.player.data
d.visits = (d.visits or 0) + 1
end)Both need their capability in the manifest, hold strings, numbers and booleans, and live in the proxy's own SQLite file. See Plugin API.
Running a command#
run(line) is the console typing a command. It needs commands = true in the manifest plus the operator's grant, and it
reaches only the commands that plugins registered. The proxy's own commands, such as send and reload, are not
reachable.
Java habits and their Lua form#
| Java | Lua |
|---|---|
| A class with fields and methods | A table with fields and functions, see methods |
null, Optional<T> |
nil |
List<T>, Map<K, V> |
One table, with ipairs or pairs |
enum |
A string such as "late", or a table of constants |
try, catch |
pcall, and only when the failure cannot be predicted |
@Inject |
Nothing: the globals are already there |
synchronized, executors |
Nothing: plugins share one thread and run one task at a time |
| Indexes from 0 | Indexes from 1 |
What to know before you port#
- No cancelling, no rewriting. Plugins that deny logins, pick the initial server or edit pings do not port.
- No plugin messaging channels from a plugin. Backends that need to move players use the BungeeCord channel, which the
operator enables per backend with
bungee-messages, see Backends and routing. - Handlers must be short. A handler that runs out of its instruction budget disables its plugin until the next
reload. Spread long work withwait. - A plugin that keeps failing is switched off and the proxy keeps running.
- Plain source only. The proxy loads the Lua source from the package, not bytecode.
The sandbox and its limits are in Plugins.
A whole plugin, both ways#
A visit counter that greets players and a /lobby command.
@Plugin(id = "visits", name = "Visits", version = "1.0.0")
public final class Visits {
private final ProxyServer server;
private final Map<UUID, Integer> counts = new ConcurrentHashMap<>();
@Inject
public Visits(ProxyServer server) { this.server = server; }
@Subscribe
public void onInit(ProxyInitializeEvent event) {
CommandMeta meta = server.getCommandManager().metaBuilder("lobby").build();
server.getCommandManager().register(meta, (SimpleCommand) inv -> {
if (inv.source() instanceof Player p) {
server.getServer("lobby").ifPresent(s -> p.createConnectionRequest(s).fly());
}
});
}
@Subscribe
public void onJoin(PostLoginEvent event) {
int n = counts.merge(event.getPlayer().getUniqueId(), 1, Integer::sum);
event.getPlayer().sendMessage(Component.text("Visit number " + n));
}
}# plugin.toml
id = "visits"
version = "1.0.0"
api = 1
[capabilities]
players_data = true-- src/main.lua
on("player.join", function(e)
local d = e.player.data
d.visits = (d.visits or 0) + 1
e.player:send("<green>Visit number {n}", { n = d.visits })
end)
cmd("lobby", function(src)
if not src.is_console then
src:connect("lobby")
end
end, { permission = "" })The Lua version also keeps the count across restarts, which the Java one does not without more code. A bigger plugin built step by step is in the Worked example.
Where to go next#
- Plugins: the package, the manifest and the limits.
- Plugin API and Plugin events.
- Worked example: a complete plugin.