| name | minecraft-plugin-dev |
| description | Develop Minecraft server plugins using the Paper/Bukkit/Spigot API for Minecraft 1.21.x. Handles creating Paper plugins with JavaPlugin, event listeners with @EventHandler, commands, schedulers (sync/async), Persistent Data Container (PDC), Adventure text components, Vault economy integration, BungeeCord/Velocity messaging, plugin.yml configuration, YAML config management, and Paper-specific enhancement APIs. Always targets Paper API 1.21.x (Java 21) with Gradle (Kotlin DSL). Distinguishes plugin development from mod development: plugins run server-side only and do not require client installation.
|
Minecraft Plugin Development Skill
Platform Overview
| Platform | Base API | Notes |
|---|
| Paper | Bukkit/Spigot + Paper extensions | Recommended; async chunk loading, Adventure native |
| Spigot | Bukkit + Spigot extensions | Legacy; fewer APIs, slower |
| Bukkit | Base API only | Avoid for new plugins |
| Folia | Paper fork | Region-threaded; requires special scheduler APIs |
Paper is the recommended target. Paper includes all Bukkit and Spigot APIs plus
significant performance improvements and additional APIs.
Routing Boundaries
Use when: the target is server-side Paper/Bukkit/Spigot plugin behavior with JavaPlugin APIs.
Do not use when: the task requires client-side installable mods or loader APIs (minecraft-modding / minecraft-multiloader).
Do not use when: the task is pure vanilla datapack/command content (minecraft-datapack / minecraft-commands-scripting).
Project Setup
settings.gradle.kts
rootProject.name = "my-plugin"
build.gradle.kts
plugins {
java
id("com.gradleup.shadow") version "8.3.0"
}
group = "com.example"
version = "1.0.0-SNAPSHOT"
repositories {
mavenCentral()
maven("https://repo.papermc.io/repository/maven-public/")
maven("https://jitpack.io")
}
dependencies {
compileOnly("io.papermc.paper:paper-api:1.21.11-R0.1-SNAPSHOT")
compileOnly("com.github.MilkBowl:VaultAPI:1.7")
}
java {
toolchain.languageVersion.set(JavaLanguageVersion.of(21))
}
tasks {
processResources {
filesMatching("plugin.yml") {
expand("version" to project.version)
}
}
shadowJar {
archiveClassifier.set("")
}
build {
dependsOn(shadowJar)
}
}
gradle/wrapper/gradle-wrapper.properties
distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip
Project Layout
my-plugin/
├── build.gradle.kts
├── settings.gradle.kts
├── gradle/
│ └── wrapper/
│ └── gradle-wrapper.properties
└── src/main/
├── java/com/example/myplugin/
│ ├── MyPlugin.java ← main class (extends JavaPlugin)
│ ├── listeners/
│ │ └── PlayerListener.java
│ ├── commands/
│ │ └── MyCommand.java
│ └── managers/
│ └── DataManager.java
└── resources/
├── plugin.yml
└── config.yml
Core Files
plugin.yml (required)
name: MyPlugin
version: "${version}"
main: com.example.myplugin.MyPlugin
description: An example Paper plugin
author: YourName
website: https://github.com/example/my-plugin
api-version: '1.21.11'
commands:
myplugin:
description: Main plugin command
usage: /myplugin <subcommand>
permission: myplugin.use
aliases: [mp]
permissions:
myplugin.use:
description: Allows use of /myplugin
default: true
myplugin.admin:
description: Admin access
default: op
Paper 1.20.5+ supports major/minor/patch api-version values.
Use api-version: '1.21.11' when you target that Paper patch specifically, or api-version: '1.21'
only when you intentionally support the broader 1.21.x line.
Main Plugin Class
package com.example.myplugin;
import com.example.myplugin.commands.MyCommand;
import com.example.myplugin.listeners.PlayerListener;
import org.bukkit.plugin.java.JavaPlugin;
public final class MyPlugin extends JavaPlugin {
private static MyPlugin instance;
@Override
public void onEnable() {
instance = this;
saveDefaultConfig();
getServer().getPluginManager().registerEvents(new PlayerListener(this), this);
var cmd = getCommand("myplugin");
if (cmd != null) {
cmd.setExecutor(new MyCommand(this));
cmd.setTabCompleter(new MyCommand(this));
}
getLogger().info("MyPlugin enabled!");
}
@Override
public void onDisable() {
getLogger().info("MyPlugin disabled.");
}
public static MyPlugin getInstance() {
instance;
}
}
Event Listeners
package com.example.myplugin.listeners;
import com.example.myplugin.MyPlugin;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.PlayerDeathEvent;
import org.bukkit.event.player.PlayerJoinEvent;
import org.bukkit.event.player.PlayerQuitEvent;
public class PlayerListener implements Listener {
private final MyPlugin plugin;
public PlayerListener(MyPlugin plugin) {
this.plugin = plugin;
}
@EventHandler(priority = EventPriority.NORMAL, ignoreCancelled = true)
public void onPlayerJoin(PlayerJoinEvent event) {
event.joinMessage(
Component.text(event.getPlayer().getName() + " joined!", NamedTextColor.GREEN)
);
}
@EventHandler
public void onPlayerQuit(PlayerQuitEvent event) {
event.quitMessage(
Component.text(event.getPlayer().getName() + " left.", NamedTextColor.YELLOW)
);
}
@EventHandler(ignoreCancelled = true)
public void onPlayerDeath(PlayerDeathEvent event) {
event.deathMessage(
Component.text(, NamedTextColor.RED)
.append(Component.text(event.getPlayer().getName(), NamedTextColor.WHITE))
.append(Component.text(, NamedTextColor.RED))
);
}
}
EventPriority order
LOWEST → LOW → NORMAL → HIGH → HIGHEST → MONITOR
Use MONITOR for logging only (never modify outcome). Use ignoreCancelled = true unless
you have a specific reason to handle cancelled events.
Cancellable events
@EventHandler
public void onBlockBreak(BlockBreakEvent event) {
if (event.getPlayer().hasPermission("myplugin.break.deny")) {
event.setCancelled(true);
event.getPlayer().sendMessage(Component.text("You cannot break blocks!", NamedTextColor.RED));
}
}
Commands
package com.example.myplugin.commands;
import com.example.myplugin.MyPlugin;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender;
import org.bukkit.command.TabCompleter;
import org.bukkit.entity.Player;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.List;
public class MyCommand implements CommandExecutor, TabCompleter {
private final MyPlugin plugin;
public MyCommand(MyPlugin plugin) {
this.plugin = plugin;
}
@Override
public boolean onCommand(@NotNull CommandSender sender, @NotNull Command command,
@NotNull String label, @NotNull String[] args) {
if (!(sender instanceof Player player)) {
sender.sendMessage(Component.text("Only players can use this command.", NamedTextColor.RED));
return true;
}
if (!player.hasPermission("myplugin.use")) {
player.sendMessage(Component.text("No permission.", NamedTextColor.RED));
return true;
}
(args.length == ) {
player.sendMessage(Component.text(, NamedTextColor.YELLOW));
;
}
(args[].toLowerCase()) {
-> {
plugin.reloadConfig();
player.sendMessage(Component.text(, NamedTextColor.GREEN));
;
}
-> {
player.sendMessage(Component.text( + plugin.getDescription().getVersion(), NamedTextColor.AQUA));
;
}
-> {
player.sendMessage(Component.text(, NamedTextColor.RED));
;
}
};
}
List<String> {
(args.length == ) {
List.of(, ).stream()
.filter(s -> s.startsWith(args[].toLowerCase()))
.toList();
}
List.of();
}
}
Schedulers
Synchronous (runs on main thread)
plugin.getServer().getScheduler().runTaskLater(plugin, () -> {
}, 20L);
plugin.getServer().getScheduler().runTaskTimer(plugin, () -> {
}, 0L, 40L);
Asynchronous (for I/O / database work)
plugin.getServer().getScheduler().runTaskAsynchronously(plugin, () -> {
String data = fetchFromDatabase();
plugin.getServer().getScheduler().runTask(plugin, () -> {
Bukkit.broadcastMessage(data);
});
});
BukkitRunnable (cancelable tasks)
new BukkitRunnable() {
int count = 0;
@Override
public void run() {
count++;
if (count >= 10) {
cancel();
return;
}
}
}.runTaskTimer(plugin, 0L, 20L);
Persistent Data Container (PDC)
PDC stores arbitrary data on any PersistentDataHolder (players, entities, items, chunks).
Data is saved with the world and persists across restarts.
import org.bukkit.NamespacedKey;
import org.bukkit.persistence.PersistentDataType;
NamespacedKey killKey = new NamespacedKey(plugin, "kill_count");
NamespacedKey flagKey = new NamespacedKey(plugin, "vip");
player.getPersistentDataContainer().set(killKey, PersistentDataType.INTEGER, 42);
player.getPersistentDataContainer().set(flagKey, PersistentDataType.BOOLEAN, true);
int kills = player.getPersistentDataContainer()
.getOrDefault(killKey, PersistentDataType.INTEGER, 0);
boolean isVip = player.getPersistentDataContainer()
.getOrDefault(flagKey, PersistentDataType.BOOLEAN, false);
boolean hasData = player.getPersistentDataContainer().has(killKey, PersistentDataType.INTEGER);
player.getPersistentDataContainer().remove(killKey);
PDC on ItemStack
ItemStack item = new ItemStack(Material.DIAMOND_SWORD);
ItemMeta meta = item.getItemMeta();
meta.getPersistentDataContainer().set(new NamespacedKey(plugin, "custom_id"),
PersistentDataType.STRING, "special_sword");
item.setItemMeta(meta);
Adventure Text Components
Paper uses Adventure natively for all text. No legacy chat colors.
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import net.kyori.adventure.text.format.TextDecoration;
import net.kyori.adventure.text.event.ClickEvent;
import net.kyori.adventure.text.event.HoverEvent;
player.sendMessage(Component.text("Hello!", NamedTextColor.GREEN));
player.sendMessage(Component.text("Bold warning", NamedTextColor.RED, TextDecoration.BOLD));
Component message = Component.text()
.append(Component.text("[Click Me]", NamedTextColor.AQUA)
.clickEvent(ClickEvent.runCommand("/myplugin info"))
.hoverEvent(HoverEvent.showText(Component.text("Run /myplugin info"))))
.append(Component.text(" to see plugin info.", NamedTextColor.WHITE))
.build();
player.sendMessage(message);
import net.kyori.adventure.text.minimessage.MiniMessage;
Component parsed = MiniMessage.miniMessage().deserialize(
"<gradient:red:yellow>Hello World</gradient>"
);
player.showTitle(Title.title(
Component.text("Welcome!", NamedTextColor.GOLD),
Component.text("To " + player.getWorld().getName(), NamedTextColor.YELLOW),
Title.Times.times(Duration.ofMillis(500), Duration.ofSeconds(3), Duration.ofMillis(500))
));
player.sendActionBar(Component.text("Health: " + player.getHealth(), NamedTextColor.RED));
Configuration (YAML)
src/main/resources/config.yml
settings:
max-players: 20
welcome-message: "<green>Welcome to the server!"
cooldown-seconds: 30
database:
host: localhost
port: 3306
name: myplugin_db
Accessing config values
saveDefaultConfig();
int maxPlayers = getConfig().getInt("settings.max-players", 20);
String message = getConfig().getString("settings.welcome-message", "Welcome!");
boolean enabled = getConfig().getBoolean("features.pvp", true);
reloadConfig();
getConfig().set("settings.max-players", 30);
saveConfig();
Custom config file
File customFile = new File(getDataFolder(), "data.yml");
if (!customFile.exists()) {
saveResource("data.yml", false);
}
FileConfiguration customConfig = YamlConfiguration.loadConfiguration(customFile);
customConfig.set("some.key", "value");
customConfig.save(customFile);
Vault Integration (Economy / Permissions)
import net.milkbowl.vault.economy.Economy;
import org.bukkit.plugin.RegisteredServiceProvider;
public class MyPlugin extends JavaPlugin {
private Economy economy;
@Override
public void onEnable() {
if (!setupEconomy()) {
getLogger().severe("Vault not found! Economy features disabled.");
}
}
private boolean setupEconomy() {
if (getServer().getPluginManager().getPlugin("Vault") == null) return false;
RegisteredServiceProvider<Economy> rsp =
getServer().getServicesManager().getRegistration(Economy.class);
if (rsp == null) return false;
economy = rsp.getProvider();
return economy != null;
}
public void chargePlayer(Player player, double amount) {
if (economy != null && economy.has(player, amount)) {
economy.withdrawPlayer(player, amount);
}
}
}
Paper-Specific APIs
Async chunk loading
world.getChunkAtAsync(x, z).thenAccept(chunk -> {
chunk.getBlock(0, 64, 0).setType(Material.GOLD_BLOCK);
});
Custom item meta
ItemStack item = new ItemStack(Material.STICK);
ItemMeta meta = item.getItemMeta();
meta.setCustomModelData(1001);
meta.displayName(Component.text("Magic Wand", NamedTextColor.LIGHT_PURPLE));
item.setItemMeta(meta);
Player profile (async)
Bukkit.createProfile(UUID.fromString("...")).update().thenAccept(profile -> {
String name = profile.getName();
});
GriefPrevention / WorldGuard bypass
if (getServer().getPluginManager().getPlugin("WorldGuard") != null) {
}
Common Tasks Checklist
Creating a new event listener
Adding a new command
Saving plugin data
Scheduling a repeating task
Build & Run
./gradlew shadowJar
./gradlew runServer
Validator Script
Use the bundled validator before publishing a Paper plugin:
./scripts/validate-plugin-layout.sh --root /path/to/plugin-project
./scripts/validate-plugin-layout.sh --root /path/to/plugin-project --strict
What it checks:
plugin.yml required keys (name, version, main, api-version)
- Main class path exists and extends
JavaPlugin
/reload anti-pattern detection in source snippets
References