BlazeBans API/Building an addon
Build one end to end
A complete addon, from an empty directory to a jar running on a server. Everything here compiles as written.
What we are building
BlazeBans Join Alerts. Three features, chosen because together they touch every part of the API:
- When a player joins, warn staff if that player has active punishments or a heavy record. This covers querying and threading.
- Let
/appealwork while a player is muted. This covers events used as exemptions. - Register the addon and report its status in
/blazebans addons. This covers addon metadata and console output.
It is about 200 lines and genuinely useful on a real server.
1. Project structure
join-alerts/
build.gradle
settings.gradle
libs/
blazebans-api-1.0.0.jar
src/main/
java/com/example/joinalerts/
JoinAlertsPlugin.java
JoinAlertListener.java
MuteExemptionListener.java
resources/
plugin.yml
config.ymlDownload the API jar from Project setup into libs/.
2. Build script
settings.gradle:
rootProject.name = 'join-alerts'build.gradle:
plugins {
id 'java'
id 'xyz.jpenilla.run-paper' version '3.0.2'
}
group = 'com.example'
version = '1.0.0'
repositories {
mavenCentral()
maven {
name = "PaperMC"
url = "https://repo.papermc.io/repository/maven-public/"
}
}
dependencies {
compileOnly("io.papermc.paper:paper-api:1.21.11-R0.1-SNAPSHOT")
compileOnly(files("libs/blazebans-api-1.0.0.jar"))
}
java {
toolchain.languageVersion = JavaLanguageVersion.of(21)
}
tasks.withType(JavaCompile).configureEach {
options.encoding = 'UTF-8'
options.release.set(21)
}
processResources {
def props = [version: version]
inputs.properties props
filteringCharset = 'UTF-8'
filesMatching('plugin.yml') {
expand props
}
}
tasks {
runServer {
minecraftVersion("1.21.11")
}
}3. plugin.yml
name: JoinAlerts
version: '${version}'
main: com.example.joinalerts.JoinAlertsPlugin
api-version: '1.21'
authors: [ you ]
description: Warns staff when a player with history joins.
depend:
- BlazeBans
permissions:
blazebans.addon.joinalerts.receive:
description: Receives join alerts.
default: opdepend guarantees BlazeBans is enabled before us.
4. config.yml
# Alert staff when a joining player has at least this many total records.
history-threshold: 3
# Always alert when the joining player has an active punishment.
alert-on-active: true
# Commands a muted player may still use. Leading slash optional.
mute-exempt-commands:
- "appeal"
- "support"5. The main class
package com.example.joinalerts;
import java.util.List;
import java.util.Locale;
import net.blazebans.api.BlazeBansApi;
import net.blazebans.api.BlazeBansProvider;
import net.blazebans.api.addon.BlazeBansAddon;
import net.blazebans.api.console.AddonConsole;
import org.bukkit.plugin.java.JavaPlugin;
public final class JoinAlertsPlugin extends JavaPlugin {
private static final String ADDON_ID = "JoinAlerts";
private BlazeBansApi blazeBans;
private AddonConsole console;
@Override
public void onEnable() {
this.console = new AddonConsole(
"JoinAlerts",
component -> getServer().getConsoleSender().sendMessage(component)
);
saveDefaultConfig();
this.blazeBans = BlazeBansProvider.optional().orElse(null);
if (this.blazeBans == null) {
this.console.error("The BlazeBans API is unavailable. Check hooks.api in settings.yml.");
registerAddon("disabled: API unavailable");
getServer().getPluginManager().disablePlugin(this);
return;
}
this.console.step("Connecting to BlazeBans " + this.blazeBans.version() + "...");
getServer().getPluginManager().registerEvents(
new JoinAlertListener(this, this.blazeBans), this);
getServer().getPluginManager().registerEvents(
new MuteExemptionListener(exemptCommands()), this);
registerAddon("enabled");
this.console.success("Join alerts are active.");
}
@Override
public void onDisable() {
BlazeBansProvider.unregisterAddon(ADDON_ID);
}
private void registerAddon(String status) {
BlazeBansProvider.registerAddon(new BlazeBansAddon(
ADDON_ID,
"Join Alerts",
getPluginMeta().getVersion(),
"Warns staff when a player with history joins.",
status
));
}
public int historyThreshold() {
return Math.max(1, getConfig().getInt("history-threshold", 3));
}
public boolean alertOnActive() {
return getConfig().getBoolean("alert-on-active", true);
}
private List<String> exemptCommands() {
return getConfig().getStringList("mute-exempt-commands").stream()
.map(command -> command.toLowerCase(Locale.ROOT).replaceFirst("^/", ""))
.filter(command -> !command.isBlank())
.toList();
}
}Note the addon is registered even on the failure path, with a status saying why. An operator running /blazebans addons then sees the reason instead of an absence.
6. The join listener
This is where threading matters. We are on the main thread when the event fires, we need three database lookups, and we need to be back on a safe thread to message anyone.
package com.example.joinalerts;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import net.blazebans.api.BlazeBansApi;
import net.blazebans.api.punishment.Punishment;
import net.blazebans.api.punishment.PunishmentType;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerJoinEvent;
public final class JoinAlertListener implements Listener {
private static final String ALERT_PERMISSION = "blazebans.addon.joinalerts.receive";
private final JoinAlertsPlugin plugin;
private final BlazeBansApi blazeBans;
public JoinAlertListener(JoinAlertsPlugin plugin, BlazeBansApi blazeBans) {
this.plugin = plugin;
this.blazeBans = blazeBans;
}
@EventHandler
public void onJoin(PlayerJoinEvent event) {
Player player = event.getPlayer();
String name = player.getName();
java.util.UUID uuid = player.getUniqueId();
// Start every lookup at once rather than chaining them.
CompletableFuture<List<Punishment>> history = this.blazeBans.punishments().history(name);
CompletableFuture<Boolean> muted = this.blazeBans.punishments().isMuted(name, uuid);
history.thenCombine(muted, (records, isMuted) -> summarise(records, isMuted))
.thenAccept(summary -> {
if (summary == null) {
return;
}
// Back onto a thread where Bukkit is safe before touching players.
Bukkit.getGlobalRegionScheduler().run(this.plugin, task -> broadcast(name, summary));
})
.exceptionally(error -> {
this.plugin.getLogger().warning(
"Could not check history for " + name + ": " + error.getMessage());
return null;
});
}
/** Returns the alert text, or null when this player is not worth alerting about. */
private String summarise(List<Punishment> records, boolean muted) {
long bans = records.stream()
.filter(record -> record.type() == PunishmentType.BAN
|| record.type() == PunishmentType.IP_BAN)
.count();
long warnings = records.stream()
.filter(record -> record.type() == PunishmentType.WARN)
.filter(Punishment::active)
.count();
boolean heavy = records.size() >= this.plugin.historyThreshold();
boolean active = this.plugin.alertOnActive() && (muted || warnings > 0);
if (!heavy && !active) {
return null;
}
StringBuilder summary = new StringBuilder();
summary.append(records.size()).append(" records");
if (bans > 0) {
summary.append(", ").append(bans).append(" bans");
}
if (warnings > 0) {
summary.append(", ").append(warnings).append(" active warnings");
}
if (muted) {
summary.append(", currently muted");
}
return summary.toString();
}
private void broadcast(String playerName, String summary) {
Component message = Component.text("[JoinAlerts] ", NamedTextColor.GOLD)
.append(Component.text(playerName, NamedTextColor.WHITE))
.append(Component.text(" joined: ", NamedTextColor.GRAY))
.append(Component.text(summary, NamedTextColor.YELLOW));
for (Player staff : Bukkit.getOnlinePlayers()) {
if (staff.hasPermission(ALERT_PERMISSION)) {
staff.sendMessage(message);
}
}
}
}Three things worth pointing out.
thenCombine runs both lookups concurrently. Chaining them with thenCompose would mean two round trips one after another for no reason.
summarise returns null for "nothing to say", and the callback returns early on it. That keeps the decision off the scheduled task.
broadcast only runs inside getGlobalRegionScheduler().run, because it iterates online players and sends messages. That is the hop from the database thread back to a safe one.
7. The mute exemption listener
package com.example.joinalerts;
import java.util.List;
import java.util.Locale;
import net.blazebans.api.event.BlazeBansMutedCommandEvent;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
public final class MuteExemptionListener implements Listener {
private final List<String> exempt;
public MuteExemptionListener(List<String> exempt) {
this.exempt = exempt;
}
@EventHandler
public void onMutedCommand(BlazeBansMutedCommandEvent event) {
String command = event.command().toLowerCase(Locale.ROOT).replaceFirst("^/", "");
String root = command.split("\\s+", 2)[0];
if (this.exempt.contains(root)) {
// Cancelling tells BlazeBans NOT to block this command.
event.setCancelled(true);
}
}
}Remember the inversion: cancelling the BlazeBans event is what allows the command.
This listener touches no Bukkit API, so it needs no scheduling even though the event fires asynchronously.
8. Build and run
./gradlew buildYour jar lands in build/libs/. Copy BlazeBans into run/plugins/ once, then:
./gradlew runServerConsole should show:
[BlazeBans JoinAlerts] Connecting to BlazeBans 1.0.0...
[BlazeBans JoinAlerts] Join alerts are active.9. Test it
/blazebans addonsJoin Alerts should be listed as enabled.
Give yourself a record and rejoin:
/warn YourName Testing
/warn YourName Testing again
/warn YourName Testing a third timeReconnect and you should see the alert. Then test the exemption:
/mute YourName 10m TestingAs that player, /appeal should still be permitted by BlazeBans while /msg is blocked.
Where to take it
The obvious next steps, each a small change:
Cache the result. A player rejoining repeatedly re-queries. A short-lived map keyed by UUID fixes it. See Threading.
Listen for changes. BlazeBansPunishmentCreatedEvent lets you invalidate a cache entry the moment a punishment lands instead of waiting for it to expire.
Add a command. /joinalerts reload reloading your own config is what operators expect.
Report status properly. Re-register the addon with a status naming the count of alerts sent, or a failure reason if lookups keep erroring.
Post it somewhere. The same summary is more useful in a staff Discord channel than in chat that scrolls away.
The full listing
Everything above in one place, in the order the files appear in the project:
build.gradleandsettings.gradlefrom step 2src/main/resources/plugin.ymlfrom step 3src/main/resources/config.ymlfrom step 4src/main/java/com/example/joinalerts/JoinAlertsPlugin.javafrom step 5src/main/java/com/example/joinalerts/JoinAlertListener.javafrom step 6src/main/java/com/example/joinalerts/MuteExemptionListener.javafrom step 7
If something does not behave, Common mistakes covers the usual causes.

