A Paper plugin whose commands and events are written in Lua.
This is the smallest useful thing you can build with
Mawu: a Paper plugin where the
Kotlin is wiring and the behaviour lives in src/main/lua.
The scripts are compiled to Luak bytecode at build time and packaged inside the jar, so the server never parses Lua at startup.
Every command and every listener below is a Lua script. Nothing is declared in
plugin.yml.
| Command | What it does |
|---|---|
/greet [player] |
Greets someone, or you |
/luainfo |
Reports the runtime, the server and how full it is |
/whois [player] |
Name, world, position, mode, health, ping |
/heal [player] |
Fills health, hunger and saturation |
/mode <mode> |
Changes your game mode |
/buff <effect> |
A minute of speed, jump, night vision or strength |
/kit |
A starter kit, with a cooldown kept in Lua |
/wand |
An enchanted named stick that inspects blocks |
/go <player> or /go <x> <y> <z> |
Teleports you |
/sethome, /home, /delhome |
A home, saved in the plugin config |
/setwarp <name>, /warp [name] |
Warps everyone can travel to |
/msg <player> <text>, /r <text> |
Private messages, with a reply target |
/sky <moment> |
Sets the time and clears the weather |
/announce <text> |
A coloured broadcast |
| Listener | What it does |
|---|---|
| Join and quit | Welcome message, and shorter join and quit lines |
| First join | Sends new players to spawn with a sound |
| Death | Rewrites the death message and keeps the inventory |
| Chat | Cancels a message with a banned word in it |
| Blocks | Protects bedrock, spawners and frames; stops TNT |
| Interact | The other half of the wand |
| Damage | No fighting in the world called lobby |
| Weather and spawning | No rain in the lobby, no natural phantoms |
| Server list ping | A gradient MOTD |
Scripts live in src/main/lua, in whatever shape suits them:
src/main/lua/
commands/greet.lua
commands/home.lua
...
events/join.lua
events/blocks.lua
...
A command:
local MiniMessage = import 'net.kyori.adventure.text.minimessage.MiniMessage'
local text = MiniMessage:miniMessage()
commands.register('greet', 'Greets a player by name', function(sender, args)
local who = args[1] or sender:getName()
sender:sendMessage(text:deserialize('<green>Hello, <white>' .. who .. '</white>!'))
end)An event:
local PlayerJoinEvent = import 'org.bukkit.event.player.PlayerJoinEvent'
events.on(PlayerJoinEvent, function(event)
local player = event:getPlayer()
player:sendMessage(text:deserialize('<gold>Welcome, ' .. player:getName() .. '!'))
event:joinMessage(text:deserialize('<green>+ <white>' .. player:getName()))
end)Nothing is registered from Kotlin for those two names. A script reaches the Paper API, Adventure and the JDK by name, on the same classpath the Kotlin next to it uses.
ExamplePlugin is the plain Java entry point and does nothing but fetch the
libraries. LuaPlugin is the wiring: it builds one MawuLane, hands the
scripts what they cannot name themselves, and runs everything Mawu packaged.
lane.expose("server", plugin.server)
lane.expose("plugin", plugin)
lane.exposeFunctions("commands", mapOf("register" to MawuFunction(::registerCommand)))
lane.exposeFunctions("events", mapOf("on" to MawuFunction(::listenTo)))
for (id in MawuScripts.ids(LuaPlugin::class.java.classLoader)) {
lane.run(id)
}commands.register collects what a script asks for and hands it to Paper's
LifecycleEvents.COMMANDS registrar as a BasicCommand; events.on takes the
event class the script imported and registers an ordinary Bukkit listener that
calls back into Lua.
That listener checks the event type before calling the script. An event class
that declares no handler list of its own shares its parent's, so a listener for
EntityDamageByEntityEvent is handed every EntityDamageEvent as well, and a
script asking about a fight would be woken by a falling anvil.
The Lua is compiled with the rest of the project, against the same classpath, so a name that does not exist fails the build rather than the server:
> Unknown classes:
src/main/lua/events/join.lua:4: no class named 'org.bukkit.event.player.PlayerJoinEventt'
src/main/lua/commands/greet.lua:8: 'net.kyori.adventure.text.minimessage.MiniMessage' has no member 'deserializee'
Both of those came up while these scripts were being written, along with
Player:isInstance(entity), which reads like Java but asks a Bukkit interface
for a method of java.lang.Class. The build named it; the server would have
waited for somebody to swing a sword.
The globals the plugin installs are declared in build.gradle.kts, so reading
one the plugin does not install fails the same way:
mawu {
knownGlobals.addAll("server", "plugin", "commands", "events")
}Two things it cannot check. A call on one of those globals, since only the
plugin knows what they hold: server:getMinecraftVersion() is checked no
further than the name server being known. And argument types, so
player:showTitle(component) passes the build and fails at runtime, because
that method wants a Title and not a Component.
./gradlew buildThe jar lands in build/libs/ at around 25 KB. Drop it into a Paper server's
plugins/ folder.
Mawu, Luak and the Kotlin standard library are downloaded the first time the
plugin runs and cached under plugins/MawuExample/libraries, the way
BlueArcade and BlueHousing do it. ExampleRuntimeDependencies lists them and
ExamplePlugin fetches them before anything that needs them is loaded.
The one library that cannot arrive that way is the downloader itself, so
net.blueva.foundation.dependencies is the only package packaged into the jar.
That is also why the entry point is plain Java: kotlin-stdlib is not on the
classpath until it has run.
This needs the legacy plugin.yml rather than paper-plugin.yml: injecting a
jar wants a URLClassLoader, and Paper's modern plugin class loader is not
one.
| Component | Requirement |
|---|---|
| JDK | 25, as Paper 26.2 requires |
| Server | Paper 26.2 |
| Network | Reachable on first boot, to fetch the libraries |
MIT. See LICENSE.