Write real Minecraft plugins in JavaScript, Lua, or Python — not a pseudo-language
BirdLibraryApi is a Paper plugin with GraalJS, LuaJ, and GraalPy embedded inside it. Just drop a .js, .lua, or .py file into the plugins/BirdApi/ folder and you can write events, commands, schedulers, and call the Bukkit API directly through the Bird object — like Skript, but without Skript's ceiling.
Installation
From source code to your first working script
- Compile the plugin
You need Maven and JDK 17 or later. Then run the build in the project folder.
You'll get a single
cd BirdLibraryApi mvn clean package
target/BirdLibraryApi.jarfile, roughly 60–80MB, since GraalJS and GraalPy are both fully embedded (LuaJ adds under 1MB) — no extra libraries needed on the server. - Drop in the jar and run it once
Copy
BirdLibraryApi.jarintoplugins/and start the server once. The plugin will automatically create theplugins/BirdApi/folder.Check pom.xml before runningpom.xmlpinspaper-apito version 1.20.4-R0.1-SNAPSHOT — if your server runs a different version, update the version number in pom.xml to match before building. - Add your first script
Drop
BirdApi-examples/example.js,example.lua, orexample.py(or your own file) intoplugins/BirdApi/— see section 12 for the Python/Lua-specific syntax. - Load the script
Run
/birdlib reload(requires thebirdlib.reloadpermission, granted to op by default), or just restart the server.Example chat output
Reloading a single file
You don't have to reload everything every time — you can reload a single file. Other running files aren't affected; their events/commands/tasks keep working.
/birdlib reload → reload every script (.js, .lua and .py) /birdlib reload example → reload example.lua if it exists, else example.py, else example.js /birdlib reload example.js → same thing, with filename tab-completion
The old /birdapi command still works — it's an alias of /birdlib
Core Concepts
Understand these before writing real scripts
Variables available in every script
| Variable | What it is |
|---|---|
| Bird | The main object — events, commands, scheduler, players, persistent storage, everything lives here |
| Bukkit | The org.bukkit.Bukkit class — call static methods directly |
| server | An instance of org.bukkit.Server |
| plugin | An instance of the BirdLibraryApi plugin itself |
| Java.type("...") | A GraalJS feature — import any Java class on the server's classpath (.js only) |
| java.type("...") | The GraalPy equivalent, used via import java (.py only) |
One file = one isolated scope
Each .js file runs in its own GraalJS context — var x in one file will never collide with var x in another file. Each .lua file likewise gets its own LuaJ Globals, and each .py file gets its own GraalPy context. You're free to split things into as many files as you like, in any mix of the three languages.
Why not just keep using Skript
Skript is great for small, quick edits without needing to know how to program. But as a project grows, you hit a real ceiling. This table lays out the comparison clearly:
| Skript | BirdLibraryApi | |
|---|---|---|
| Language | An English-like DSL with no real function/closure/class support | Full ECMAScript — functions, closures, classes, Array/Math/JSON |
| Bukkit API access | Only what core + addons expose; new features wait on addons | Java.type(...) calls any class instantly, no addon needed |
| Cross-file communication | Essentially one namespace; splitting files gets awkward | Bird.on/emit fires events across files directly |
| Persistent data | The variable system works, but it's an opaque binary format, hard to edit externally | setData/getData writes plain .properties files you can read by eye |
| Reload safety | Reloading a large script set is often all-or-nothing | Each file loads/unloads independently; listeners/commands/tasks are tracked and auto-cleared |
| Debugging | Errors are in Skript's own language; addon stack traces are often unreadable | Real JS/Java stack traces; use console.log/Bird.log anywhere |
| Internet access | Needs a separate HTTP addon, most of which are unmaintained | Built-in Bird.fetch(...) plus Bird.sendDiscordWebhook(...) |
Events
Bird.onEvent(eventClassName, [priority,] callback)
Register a Bukkit event listener using its fully-qualified class name — no need to edit plugin.yml at all.
Bird.onEvent("org.bukkit.event.player.PlayerJoinEvent", function (event) { var player = event.getPlayer(); event.setJoinMessage("&a[+] " + player.getName() + " joined"); player.sendMessage("&bWelcome! (powered by BirdLibraryApi)"); }); // Priority can be set (LOWEST, LOW, NORMAL, HIGH, HIGHEST, MONITOR) Bird.onEvent("org.bukkit.event.block.BlockBreakEvent", "HIGH", function (event) { var Material = Java.type("org.bukkit.Material"); if (event.getBlock().getType() === Material.BEDROCK) { event.setCancelled(true); } });
Commands
Bird.onCommand(name, [permission,] callback, [tabComplete])
Register commands dynamically — no need to declare them in plugin.yml at all. Permission and tab-completion are both optional.
Bird.onCommand("goto", "example.goto", function (sender, label, args) { if (args.length < 1) { sender.sendMessage("&cUsage: /goto <player>"); return; } var target = Bird.getPlayer(args[0]); if (target === null) { sender.sendMessage("&cPlayer not found: " + args[0]); return; } sender.teleport(target.getLocation()); }, function (sender, alias, args) { // Suggest online player names for the first argument if (args.length === 1) { return java.util.Arrays.asList(Bird.getPlayerNames()); } return java.util.Collections.emptyList(); });
All available overload forms:
| Call form | Result |
|---|---|
| onCommand(name, callback) | No permission, no tab-complete |
| onCommand(name, permission, callback) | Requires a permission to use |
| onCommand(name, callback, tabComplete) | Has tab-complete, no permission required |
| onCommand(name, permission, callback, tabComplete) | Both features included |
Scheduler
Wraps the Bukkit scheduler for you — tasks are automatically cancelled when the script is unloaded/reloaded
Bird.runTask(function () { /* runs next tick, on the main thread */ }); Bird.runTaskLater(function () { /* runs once, after N ticks */ }, 40); var id = Bird.runTaskTimer(function () { /* runs repeatedly every N ticks */ }, 0, 20); Bird.cancelTask(id); // Off the main thread — never touch the Bukkit API (player/block) in here Bird.runTaskAsync(function () { /* heavy work, HTTP calls, file I/O */ });
20 ticks = 1 second. Every time you call Bird.runTask*, the system tracks it automatically; when the script is reloaded, the task is cancelled automatically — no leftover tasks to worry about.
Synchronous waiting with sleep()
Usable only inside Bird.runTaskAsync(...), because sleeping on the main thread would freeze the whole server. (Named sleep/waitTicks instead of wait because Java's Object already has a wait() method for thread locks — that would definitely cause confusion.)
Bird.onCommand("sequence", function (sender, label, args) {
Bird.runTaskAsync(function () {
Bird.tell(sender, "&e3...");
Bird.sleep(1);
Bird.tell(sender, "&e2...");
Bird.sleep(1);
Bird.tell(sender, "&e1...");
Bird.sleep(1);
Bird.runTask(function () {
// Back on the main thread — safe to touch world/player
Bird.tell(sender, "&aGO!");
});
});
});
Players & Messages
Chat, action bar, title, player status
Sending messages: tell (private) vs broadcast (everyone)
Bird.tell(player, "&7This message is just for you");
Bird.tell("Notch", "&7Hey Notch!"); // true if online, false if not found
Bird.broadcastChat("&aEvent starting now!");
Bird.broadcastChat("&7[Staff] ...", "example.staff"); // only sent to those with this permission
Action bar & Title
The action bar is a small message above the hotbar; the title is large text in the center of the screen. Timing values are in ticks (20 ticks = 1 second) — leave them out to use Minecraft's defaults (fade-in 0.5s / stay 3.5s / fade-out 1s).
Bird.sendActionBar(player, "&c⚠ Low health!");
Bird.broadcastTitle("&6&lBOSS DEFEATED", "&7Well done, everyone!");
Bird.broadcastTitle("&e&lWAVE 3", "", 5, 40, 10); // custom fadeIn/stay/fadeOut
Commonly used player utilities
| Method | What it does |
|---|---|
| Bird.getHealth / setHealth | Read/set health — setHealth clamps automatically so it never exceeds max health |
| Bird.getFood / setFood | Hunger level, 0–20 |
| Bird.addPotionEffect(p, name, sec, amp) | Apply a potion effect like "SPEED" for sec seconds at level amp (0 = level I) |
| Bird.getNearbyPlayers(p, radius) | Online players in the same world within radius blocks |
| Bird.getPing / getPlayerUUID | Latency (ms) and the UUID, which stays stable even if the name changes |
| Bird.setGameMode / getGameMode | Set/read the game mode; accepts a string name like "CREATIVE" |
Items, Sound & Effects
Give items, play sounds, spawn particles, strike lightning
Bird.giveItem(player, "DIAMOND_SWORD", 1, "&b&lFrost Blade", ["&7A blade forged in ice", "&7+5 Attack Damage"]); Bird.playSound(player, "ENTITY_PLAYER_LEVELUP"); Bird.spawnParticle(player, "HAPPY_VILLAGER", 15); Bird.strikeLightning("world", 100, 64, -230, true); // true = effect only, no damage
Block detection
var looking = Bird.getTargetBlock(player, 10); // the block being looked at, up to 10 blocks away
var underfoot = Bird.getBlockPlayerIsOn(player); // the block under the player's feet
if (underfoot.getType().name() === "LAVA") {
Bird.tell(player, "&cYou're standing in lava!");
}
Cooldowns
Good for limiting how often a player can use a command/skill — cooldowns can be shared across scripts via the same key.
if (Bird.hasCooldown("kit", sender)) {
var left = Bird.getCooldownRemaining("kit", sender);
Bird.tell(sender, "&cWait another " + Math.ceil(left) + "s");
} else {
Bird.setCooldown("kit", sender, 300); // 5 minutes
// ... give the item ...
}
Persistent Storage
Two systems: small key/value pairs and full files
Key/Value: setData / getData
Good for small bits of data like counters, toggles, timestamps — stored as a plugins/BirdApi/data/<scriptname>.properties file, loaded back automatically every time the script starts. Values are always stored as strings, so convert numbers yourself with parseInt/parseFloat.
var key = "joins_" + player.getUniqueId();
var count = parseInt(Bird.getData(key, "0")) + 1;
Bird.setData(key, String(count));
Bird.getData("missingKey"); // → null
Bird.getData("missingKey", "default"); // → "default"
Bird.removeData(key);
Bird.getDataKeys(); // → list of all stored keys
Full files: saveFile / readFile
Every script gets its own private folder at plugins/BirdApi/files/<scriptname>/ for logs, exports, small JSON databases, and so on.
var raw = Bird.readFile("playerstats.json");
var stats = raw ? JSON.parse(raw) : {};
stats[sender.getName()] = (stats[sender.getName()] || 0) + 1;
Bird.saveFile("playerstats.json", JSON.stringify(stats, null, 2));
"../" or absolute pathsFile extension blacklist:
exe bat cmd dll so autorun ps1 ps2 psm1 php sh bash vbs vbe wsf wsh jse jar msi scr are always blocked, since these can execute code on their own
Cross-script Events
Bird.on(name, cb) / Bird.emit(name, data)
Let one script fire a custom-named event, and have other loaded scripts subscribe and listen for it — very useful when splitting a large project into multiple files that don't need to know about each other directly.
// ---- economy.js ----
Bird.emit("coins-added", { player: player.getName(), amount: 50 });
// ---- logging.js (a completely separate file) ----
Bird.on("coins-added", function (data) {
Bird.log(data.player + " received " + data.amount + " coins");
});
Bird.on are removed automatically when that script is unloaded/reloaded — no need to write your own cleanup codeCalling External APIs
Bird.fetch(...) and Discord webhooks
Requests automatically run off the main thread, but the callback is always invoked back on the main thread — you can safely touch players/blocks inside the callback without managing threads yourself.
Bird.fetch("https://api.example.com/status", function (res) {
if (res.ok) {
Bird.log("Status: " + res.status + " body: " + res.body);
} else {
Bird.warn("Request failed: " + res.status);
}
});
// POST with a JSON body
Bird.fetch("https://api.example.com/events", "POST",
JSON.stringify({ type: "join" }),
function (res) { Bird.log(res.body); });
Discord webhook
Bird.sendDiscordWebhook(webhookUrl, "A player just found a diamond! 💎");
// rich embed: title, description, hex color
Bird.sendDiscordEmbed(webhookUrl, "Player joined",
playerName + " joined the server", "#57F287");
Error Handling
One broken script doesn't bring down the whole server
- The BirdLibraryApi plugin itself will never be disabled — every code path that loads a script is wrapped in try-catch. A broken file is simply skipped, while other successfully loaded files keep working normally.
- An error that occurs during load (a syntax error) is reported straight to chat immediately when you run
/birdlib reload— no need to dig through the console log. - An error that occurs while a handler runs (e.g. an NPE in your own code) is logged to console tagged with the file/event that failed, without affecting other handlers or crashing the server.
Bird.log("Loading broken-example.js...")
Bird.onCommand("broken" function (sender, label, args) {
sender.sendMessage("This line will never be reached");
});
Notice the missing , between "broken" and function — this file ships with the project (BirdApi-examples/broken-example.js) specifically to test the error-reporting system.
Python & Lua Scripting
The same Bird API, two more languages to write it in
Everything documented on this page works identically from .py and .lua files — same Bird, Bukkit, server, and plugin globals, same events/commands/scheduler/storage. Drop a .py or .lua file into plugins/BirdApi/ right alongside your .js files and it just works — the engine is picked automatically per file, based on its extension. Only the calling syntax changes.
Python — powered by GraalPy
.py scripts run on GraalPy, GraalVM's Python 3 runtime. Import any Java class with import java then java.type("...") — the equivalent of Java.type(...) in JavaScript — and call methods on Java objects with plain Python dot-syntax.
import java
Material = java.type("org.bukkit.Material")
Bird.log("Loading example.py...")
def on_join(event):
player = event.getPlayer()
event.setJoinMessage("&a[+] " + player.getName() + " joined")
player.sendMessage("&bWelcome! (powered by BirdLibraryApi)")
Bird.onEvent("org.bukkit.event.player.PlayerJoinEvent", on_join)
def on_block_break(event):
if event.getBlock().getType() == Material.BEDROCK:
event.setCancelled(True)
# Priority can be set too, same as JS (LOWEST, LOW, NORMAL, HIGH, HIGHEST, MONITOR)
Bird.onEvent("org.bukkit.event.block.BlockBreakEvent", "HIGH", on_block_break)
def hello_command(sender, label, args):
sender.sendMessage("&eHello from Python! You sent " + str(len(args)) + " argument(s)")
Bird.onCommand("hello", hello_command)
A few practical differences from the JavaScript side:
- There's no
Java.type(...)in Python —import javathenjava.type("org.bukkit.Material")is the equivalent, and you can also writefrom java.util import ArrayList-style imports for anything under thejavapackage. - Event/command/tab-complete callbacks and
Bird.runTask*just take a plain Python function or lambda — same as passing a JS function. - Python has no
===; use==. Use.equals(...)when you specifically need Java's notion of equality on host objects (e.g. comparing twoPlayerinstances). - Each
.pyfile gets its own GraalPy context, isolated from every other script the same way.js/.luafiles are.
.py scripts to load/reload noticeably slower than .js/.lua ones — especially the first script loaded after a server (re)start.Lua — powered by LuaJ
.lua scripts run on LuaJ. Since Lua doesn't have JS-style dot-call-with-implicit-this, use Lua's colon syntax to call methods on the injected globals.
Bird:log("Loading example.lua...")
local Material = luajava.bindClass("org.bukkit.Material")
Bird:onEvent("org.bukkit.event.player.PlayerJoinEvent", function(event)
local player = event:getPlayer()
player:sendMessage("&bWelcome to the server!")
end)
Bird:onCommand("hello", function(sender, label, args)
sender:sendMessage("&eHello from Lua! You sent " .. #args .. " argument(s)")
end)
A few practical differences from the JavaScript side:
- There's no
Java.type(...)in Lua, but LuaJ ships theluajavalibrary automatically —luajava.bindClass("org.bukkit.Material")is the equivalent, giving back a class reference for static access (Material:valueOf("BEDROCK")) or comparisons. - Java arrays passed back into Lua (like
argsinonCommand) stay backed by the real Java array, so they're indexed starting at 0, not Lua's usual 1-based tables. Use#argsfor the length. - Lua has no
===; use==. String concatenation is..instead of+. - LuaJ is a pure-Java interpreter with no native/JNI dependency — simpler to ship, and much lighter to load than GraalPy, but without a JIT it's slower than GraalJS/GraalPy for CPU-heavy scripts. Fine for typical event/command handlers.
Reloading by file name
All three extensions work with /birdlib reload, including the shorthand form that omits the extension — it picks whichever of the three actually exists on disk (preferring .lua, then .py, then falling back to .js):
/birdlib reload → reload every script (.js, .lua and .py) /birdlib reload example → reload example.lua if it exists, else example.py, else example.js /birdlib reload example.py → reload example.py specifically
Full Example
All features combined in one file, from the BirdApi-examples/ folder
This script is a real example shipped with the project, covering everything from basic events to schedulers, cooldowns, storage, and cross-script custom events. Try copying it into plugins/BirdApi/example.js and running /birdlib reload right away.
Bird.log("Loading example.js...");
// Greet the player on join + permanently count how many times they've joined
Bird.onEvent("org.bukkit.event.player.PlayerJoinEvent", function (event) {
var player = event.getPlayer();
event.setJoinMessage("§a[+] " + player.getName() + " joined the server");
player.sendMessage("§bWelcome to the server! (powered by BirdLibraryApi)");
var key = "joins_" + player.getUniqueId();
var count = parseInt(Bird.getData(key, "0")) + 1;
Bird.setData(key, String(count));
if (count === 1) {
Bird.broadcast("§d" + player.getName() + " is joining for the first time, welcome them!");
}
});
// Prevent breaking Bedrock (example of accessing the Bukkit API directly)
Bird.onEvent("org.bukkit.event.block.BlockBreakEvent", function (event) {
var Material = Java.type("org.bukkit.Material");
if (event.getBlock().getType() === Material.BEDROCK) {
event.setCancelled(true);
event.getPlayer().sendMessage("§cYou can't break Bedrock!");
}
});
// /kit - gives items with a cooldown + fires a custom event for other scripts to listen for
Bird.onCommand("kit", function (sender, label, args) {
if (Bird.hasCooldown("kit", sender)) {
var left = Bird.getCooldownRemaining("kit", sender);
Bird.tell(sender, "&cYou can claim another kit in " + Math.ceil(left) + "s");
return;
}
Bird.giveItem(sender, "IRON_SWORD", 1, "&b&lStarter Sword", ["&7Given by /kit"]);
Bird.giveItem(sender, "BREAD", 8);
Bird.playSound(sender, "ENTITY_PLAYER_LEVELUP");
Bird.spawnParticle(sender, "HAPPY_VILLAGER", 15);
Bird.setCooldown("kit", sender, 300);
Bird.tell(sender, "&aHere's your starter kit!");
Bird.emit("kit-claimed", { player: sender.getName() });
});
Bird.on("kit-claimed", function (data) {
Bird.log(data.player + " claimed the starter kit");
});
// /countdown - scheduler example
Bird.onCommand("countdown", function (sender, label, args) {
var seconds = args.length > 0 ? parseInt(args[0]) : 5;
var remaining = [seconds];
var taskId = Bird.runTaskTimer(function () {
if (remaining[0] <= 0) {
Bird.broadcastTitle("§a§lGO!", "");
Bird.cancelTask(taskId);
return;
}
Bird.broadcastActionBar("§e" + remaining[0] + "...");
remaining[0] = remaining[0] - 1;
}, 0, 20);
});
Bird.log("example.js loaded — /kit and /countdown are ready");
Full API Reference
A condensed lookup table for when you're scripting — no need to scroll through the whole page
| Category | Method |
|---|---|
| Event | onEvent(class, [priority,] cb) |
| Command | onCommand(name, [perm,] cb, [tabCb]) |
| Scheduler | runTask / runTaskLater / runTaskTimer / runTaskAsync / runTaskTimerAsync / cancelTask |
| Sleep | sleep(sec) / waitTicks(n) — usable only inside runTaskAsync |
| Chat | tell / broadcast / broadcastChat |
| Action bar | sendActionBar / broadcastActionBar |
| Title | sendTitle / broadcastTitle |
| Items | giveItem / hasItem / removeItem / getItemInHand / clearInventory |
| Sound/Particles | playSound / broadcastSound / spawnParticle / strikeLightning |
| Teleport | teleport(player, x, y, z, [world]) |
| Blocks | getBlock / getBlockType / isBlockType / setBlockType / getTargetBlock / getBlockPlayerIsOn |
| Cooldown | setCooldown / hasCooldown / getCooldownRemaining / clearCooldown |
| Players | getPlayer / getPlayerExact / getOnlinePlayers / getPlayerNames / getNearbyPlayers |
| Player status | getHealth/setHealth · getFood/setFood · addPotionEffect/removePotionEffect/hasPotionEffect · giveExp · getPing · getPlayerUUID |
| World | getWorldNames · getWorldTime/setWorldTime · setWeather/isStorming · isNight |
| Storage (key/value) | setData / getData / removeData / getDataKeys / saveData |
| Storage (files) | saveFile / readFile / fileExists / deleteFile / listFiles |
| Custom event | on(name, cb) / emit(name, data) |
| Internet | fetch(url, [method,] [body,] [headers,] cb) / sendDiscordWebhook / sendDiscordEmbed |
| Internal commands | runCommand(cmd) / runCommandAs(player, cmd) |
| Misc | random / colorize / stripColor / formatTime / log / warn |