Skip to content
Basalt Proxy

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#

java
@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");
    }
}
toml
# plugin.toml
id = "lobbytools"
name = "Lobby Tools"
version = "1.0.0"
authors = ["Alex"]
api = 1
main = "src/main.lua"
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
java
@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());
    }
}
lua
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 is e.player, with the flat fields e.player_name, e.player_uuid and e.server next to it. Every event is in Plugin events.
  • Priorities are FIRST, EARLY, NORMAL, LATE and LAST, the same five as PostOrder.
  • player.server_change fires when a player moves to another backend, so it plays the part of ServerPostConnectEvent with a previous server. The first backend is reported by player.join.
  • A filter is an option: on("player.join", { server = "lobby" }, fn) replaces the if that 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.login carries only player_name and player_uuid: the player has no server yet and is not in players.online().

Players and servers#

java
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();
lua
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#

java
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;
    }
    // ...
});
lua
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#

java
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();
lua
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#

java
Path file = dataDirectory.resolve("config.toml");
// load it with a TOML or YAML library, fall back to defaults...
String lobby = config.getString("lobby", "lobby");
toml
# resources/config.toml
lobby = "lobby"
max-players = 100
lua
local lobby = config.lobby or "lobby"
local limit = config.max_players          -- a key with a dash also works with an underscore

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

toml
welcome = "Welcome back, {name}. Visit number {visits}."
lua
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
toml
# plugin.toml
[capabilities]
storage = true
players_data = true
lua
on("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 with wait.
  • 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.

java
@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));
    }
}
toml
# plugin.toml
id = "visits"
version = "1.0.0"
api = 1

[capabilities]
players_data = true
lua
-- 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#

Edit this page