Minecraft Plugin Development Skill
| 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
kotlin
1rootProject.name = "my-plugin"
build.gradle.kts
kotlin
1plugins {
2 java
3 id("com.gradleup.shadow") version "8.3.0"
4}
5
6group = "com.example"
7version = "1.0.0-SNAPSHOT"
8
9repositories {
10 mavenCentral()
11 maven("https://repo.papermc.io/repository/maven-public/")
12 // For Vault (economy API)
13 maven("https://jitpack.io")
14}
15
16dependencies {
17 compileOnly("io.papermc.paper:paper-api:1.21.11-R0.1-SNAPSHOT")
18 // Optional: Vault economy/permission integration
19 compileOnly("com.github.MilkBowl:VaultAPI:1.7")
20}
21
22java {
23 toolchain.languageVersion.set(JavaLanguageVersion.of(21))
24}
25
26tasks {
27 processResources {
28 // Substitutes ${version} in plugin.yml with the Gradle project version
29 filesMatching(listOf("plugin.yml", "paper-plugin.yml")) {
30 expand("version" to project.version)
31 }
32 }
33 shadowJar {
34 archiveClassifier.set("")
35 }
36 build {
37 dependsOn(shadowJar)
38 }
39}
gradle/wrapper/gradle-wrapper.properties
properties
1distributionUrl=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
├── paper-plugin.yml ← optional, Paper-only metadata
└── config.yml
Core Files
plugin.yml (Bukkit-compatible default)
yaml
1name: MyPlugin
2version: "${version}"
3main: com.example.myplugin.MyPlugin
4description: An example Paper plugin
5author: YourName
6website: https://github.com/example/my-plugin
7api-version: '1.21.11'
8
9commands:
10 myplugin:
11 description: Main plugin command
12 usage: /myplugin <subcommand>
13 permission: myplugin.use
14 aliases: [mp]
15
16permissions:
17 myplugin.use:
18 description: Allows use of /myplugin
19 default: true
20 myplugin.admin:
21 description: Admin access
22 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.
In this repo, the validator accepts 1.21 plus positive 1.21.<patch> values on the 1.21 line.
Patches newer than the repo's current example patch (1.21.11) are allowed but warned so future
Paper updates do not force an immediate validator edit.
Values such as 1.21.0, 1.21.01, or 1.22 are rejected.
Use paper-plugin.yml when you need Paper-specific metadata such as folia-supported
or server/bootstrap dependency ordering. Keep plugin.yml if you must stay portable
to Bukkit-derived servers that do not understand the Paper-specific file.
yaml
1name: MyPlugin
2version: "${version}"
3main: com.example.myplugin.MyPlugin
4api-version: '1.21.11'
5folia-supported: true
6
7dependencies:
8 server:
9 Vault:
10 load: BEFORE
11 required: false
Main Plugin Class
java
1package com.example.myplugin;
2
3import com.example.myplugin.commands.MyCommand;
4import com.example.myplugin.listeners.PlayerListener;
5import org.bukkit.plugin.java.JavaPlugin;
6
7public final class MyPlugin extends JavaPlugin {
8
9 private static MyPlugin instance;
10
11 @Override
12 public void onEnable() {
13 instance = this;
14 saveDefaultConfig();
15
16 // Register listeners
17 getServer().getPluginManager().registerEvents(new PlayerListener(this), this);
18
19 // Register commands
20 var cmd = getCommand("myplugin");
21 if (cmd != null) {
22 cmd.setExecutor(new MyCommand(this));
23 cmd.setTabCompleter(new MyCommand(this));
24 }
25
26 getLogger().info("MyPlugin enabled!");
27 }
28
29 @Override
30 public void onDisable() {
31 getLogger().info("MyPlugin disabled.");
32 }
33
34 public static MyPlugin getInstance() {
35 return instance;
36 }
37}
Event Listeners
java
1package com.example.myplugin.listeners;
2
3import com.example.myplugin.MyPlugin;
4import net.kyori.adventure.text.Component;
5import net.kyori.adventure.text.format.NamedTextColor;
6import org.bukkit.event.EventHandler;
7import org.bukkit.event.EventPriority;
8import org.bukkit.event.Listener;
9import org.bukkit.event.entity.PlayerDeathEvent;
10import org.bukkit.event.player.PlayerJoinEvent;
11import org.bukkit.event.player.PlayerQuitEvent;
12
13public class PlayerListener implements Listener {
14
15 private final MyPlugin plugin;
16
17 public PlayerListener(MyPlugin plugin) {
18 this.plugin = plugin;
19 }
20
21 @EventHandler(priority = EventPriority.NORMAL, ignoreCancelled = true)
22 public void onPlayerJoin(PlayerJoinEvent event) {
23 event.joinMessage(
24 Component.text(event.getPlayer().getName() + " joined!", NamedTextColor.GREEN)
25 );
26 }
27
28 @EventHandler
29 public void onPlayerQuit(PlayerQuitEvent event) {
30 event.quitMessage(
31 Component.text(event.getPlayer().getName() + " left.", NamedTextColor.YELLOW)
32 );
33 }
34
35 @EventHandler(ignoreCancelled = true)
36 public void onPlayerDeath(PlayerDeathEvent event) {
37 // Modify death message using Adventure components
38 event.deathMessage(
39 Component.text("☠ ", NamedTextColor.RED)
40 .append(Component.text(event.getPlayer().getName(), NamedTextColor.WHITE))
41 .append(Component.text(" died!", NamedTextColor.RED))
42 );
43 }
44}
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
java
1@EventHandler
2public void onBlockBreak(BlockBreakEvent event) {
3 if (event.getPlayer().hasPermission("myplugin.break.deny")) {
4 event.setCancelled(true);
5 event.getPlayer().sendMessage(Component.text("You cannot break blocks!", NamedTextColor.RED));
6 }
7}
Commands
java
1package com.example.myplugin.commands;
2
3import com.example.myplugin.MyPlugin;
4import net.kyori.adventure.text.Component;
5import net.kyori.adventure.text.format.NamedTextColor;
6import org.bukkit.command.Command;
7import org.bukkit.command.CommandExecutor;
8import org.bukkit.command.CommandSender;
9import org.bukkit.command.TabCompleter;
10import org.bukkit.entity.Player;
11import org.jetbrains.annotations.NotNull;
12import org.jetbrains.annotations.Nullable;
13
14import java.util.List;
15
16public class MyCommand implements CommandExecutor, TabCompleter {
17
18 private final MyPlugin plugin;
19
20 public MyCommand(MyPlugin plugin) {
21 this.plugin = plugin;
22 }
23
24 @Override
25 public boolean onCommand(@NotNull CommandSender sender, @NotNull Command command,
26 @NotNull String label, @NotNull String[] args) {
27 if (!(sender instanceof Player player)) {
28 sender.sendMessage(Component.text("Only players can use this command.", NamedTextColor.RED));
29 return true;
30 }
31
32 if (!player.hasPermission("myplugin.use")) {
33 player.sendMessage(Component.text("No permission.", NamedTextColor.RED));
34 return true;
35 }
36
37 if (args.length == 0) {
38 player.sendMessage(Component.text("Usage: /myplugin <reload|info>", NamedTextColor.YELLOW));
39 return true;
40 }
41
42 return switch (args[0].toLowerCase()) {
43 case "reload" -> {
44 plugin.reloadConfig();
45 player.sendMessage(Component.text("Config reloaded.", NamedTextColor.GREEN));
46 yield true;
47 }
48 case "info" -> {
49 player.sendMessage(Component.text("Version: " + plugin.getDescription().getVersion(), NamedTextColor.AQUA));
50 yield true;
51 }
52 default -> {
53 player.sendMessage(Component.text("Unknown subcommand.", NamedTextColor.RED));
54 yield false;
55 }
56 };
57 }
58
59 @Override
60 public @Nullable List<String> onTabComplete(@NotNull CommandSender sender, @NotNull Command command,
61 @NotNull String label, @NotNull String[] args) {
62 if (args.length == 1) {
63 return List.of("reload", "info").stream()
64 .filter(s -> s.startsWith(args[0].toLowerCase()))
65 .toList();
66 }
67 return List.of();
68 }
69}
Schedulers
For classic Paper plugins, BukkitScheduler is still fine. If you claim Folia support,
move entity, region, and global work onto the Folia-aware schedulers instead of assuming
one global main thread.
Synchronous (runs on main thread)
java
1// Run once after 20 ticks (1 second)
2plugin.getServer().getScheduler().runTaskLater(plugin, () -> {
3 // safe to access Bukkit API here
4}, 20L);
5
6// Repeating task every 40 ticks (2 seconds), starts after 0 ticks
7plugin.getServer().getScheduler().runTaskTimer(plugin, () -> {
8 // runs on main thread
9}, 0L, 40L);
Asynchronous (for I/O / database work)
java
1// Never touch Bukkit API in async tasks!
2plugin.getServer().getScheduler().runTaskAsynchronously(plugin, () -> {
3 // safe: file I/O, HTTP requests, DB queries
4 String data = fetchFromDatabase();
5 // Switch back to main thread to use Bukkit API
6 plugin.getServer().getScheduler().runTask(plugin, () -> {
7 Bukkit.broadcastMessage(data);
8 });
9});
BukkitRunnable (cancelable tasks)
java
1new BukkitRunnable() {
2 int count = 0;
3
4 @Override
5 public void run() {
6 count++;
7 if (count >= 10) {
8 cancel(); // stop after 10 executions
9 return;
10 }
11 // task logic
12 }
13}.runTaskTimer(plugin, 0L, 20L);
Folia-safe scheduling
java
1// Player-bound work: stays with the player's owning region
2player.getScheduler().run(plugin, task -> {
3 player.sendActionBar(Component.text("Checkpoint reached"));
4}, null);
5
6// Location / chunk-bound work
7plugin.getServer().getRegionScheduler().run(plugin, location, task -> {
8 location.getBlock().setType(Material.GOLD_BLOCK);
9});
10
11// Global coordination that is not tied to one region
12plugin.getServer().getGlobalRegionScheduler().run(plugin, task -> {
13 Bukkit.dispatchCommand(Bukkit.getConsoleSender(), "save-all");
14});
15
16// Async I/O remains on the async scheduler
17plugin.getServer().getAsyncScheduler().runNow(plugin, task -> {
18 writeAuditLog();
19});
If you need to support both Paper and Folia, hide scheduling behind your own interface
instead of scattering scheduler calls throughout listeners and commands.
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.
java
1import org.bukkit.NamespacedKey;
2import org.bukkit.persistence.PersistentDataType;
3
4// Define keys (reuse instances — create once in your plugin class)
5NamespacedKey killKey = new NamespacedKey(plugin, "kill_count");
6NamespacedKey flagKey = new NamespacedKey(plugin, "vip");
7
8// Write
9player.getPersistentDataContainer().set(killKey, PersistentDataType.INTEGER, 42);
10player.getPersistentDataContainer().set(flagKey, PersistentDataType.BOOLEAN, true);
11
12// Read
13int kills = player.getPersistentDataContainer()
14 .getOrDefault(killKey, PersistentDataType.INTEGER, 0);
15
16boolean isVip = player.getPersistentDataContainer()
17 .getOrDefault(flagKey, PersistentDataType.BOOLEAN, false);
18
19// Check existence
20boolean hasData = player.getPersistentDataContainer().has(killKey, PersistentDataType.INTEGER);
21
22// Remove
23player.getPersistentDataContainer().remove(killKey);
PDC on ItemStack
java
1ItemStack item = new ItemStack(Material.DIAMOND_SWORD);
2item.editMeta(meta -> meta.getPersistentDataContainer().set(
3 new NamespacedKey(plugin, "custom_id"),
4 PersistentDataType.STRING,
5 "special_sword"
6));
PDC on chunks or worlds
java
1NamespacedKey arenaKey = new NamespacedKey(plugin, "arena_id");
2
3chunk.getPersistentDataContainer().set(arenaKey, PersistentDataType.STRING, "spawn");
4
5String arenaId = chunk.getPersistentDataContainer()
6 .getOrDefault(arenaKey, PersistentDataType.STRING, "unknown");
Adventure Text Components
Paper uses Adventure natively for all text. No legacy chat colors.
java
1import net.kyori.adventure.text.Component;
2import net.kyori.adventure.text.format.NamedTextColor;
3import net.kyori.adventure.text.format.TextDecoration;
4import net.kyori.adventure.text.event.ClickEvent;
5import net.kyori.adventure.text.event.HoverEvent;
6
7// Simple components
8player.sendMessage(Component.text("Hello!", NamedTextColor.GREEN));
9player.sendMessage(Component.text("Bold warning", NamedTextColor.RED, TextDecoration.BOLD));
10
11// Compound component
12Component message = Component.text()
13 .append(Component.text("[Click Me]", NamedTextColor.AQUA)
14 .clickEvent(ClickEvent.runCommand("/myplugin info"))
15 .hoverEvent(HoverEvent.showText(Component.text("Run /myplugin info"))))
16 .append(Component.text(" to see plugin info.", NamedTextColor.WHITE))
17 .build();
18player.sendMessage(message);
19
20// MiniMessage (recommended for config-driven text)
21import net.kyori.adventure.text.minimessage.MiniMessage;
22Component parsed = MiniMessage.miniMessage().deserialize(
23 "<gradient:red:yellow>Hello World</gradient>"
24);
25
26// Titles / action bars
27player.showTitle(Title.title(
28 Component.text("Welcome!", NamedTextColor.GOLD),
29 Component.text("To " + player.getWorld().getName(), NamedTextColor.YELLOW),
30 Title.Times.times(Duration.ofMillis(500), Duration.ofSeconds(3), Duration.ofMillis(500))
31));
32player.sendActionBar(Component.text("Health: " + player.getHealth(), NamedTextColor.RED));
Configuration (YAML)
src/main/resources/config.yml
yaml
1# Default config
2settings:
3 max-players: 20
4 welcome-message: "<green>Welcome to the server!"
5 cooldown-seconds: 30
6
7database:
8 host: localhost
9 port: 3306
10 name: myplugin_db
Accessing config values
java
1// In onEnable():
2saveDefaultConfig(); // writes config.yml if absent
3
4// Reading values
5int maxPlayers = getConfig().getInt("settings.max-players", 20);
6String message = getConfig().getString("settings.welcome-message", "Welcome!");
7boolean enabled = getConfig().getBoolean("features.pvp", true);
8
9// Reloading
10reloadConfig();
11
12// Writing values
13getConfig().set("settings.max-players", 30);
14saveConfig();
Custom config file
java
1File customFile = new File(getDataFolder(), "data.yml");
2if (!customFile.exists()) {
3 saveResource("data.yml", false); // copies from resources/
4}
5FileConfiguration customConfig = YamlConfiguration.loadConfiguration(customFile);
6customConfig.set("some.key", "value");
7customConfig.save(customFile);
Vault Integration (Economy / Permissions)
java
1import net.milkbowl.vault.economy.Economy;
2import org.bukkit.plugin.RegisteredServiceProvider;
3
4public class MyPlugin extends JavaPlugin {
5 private Economy economy;
6
7 @Override
8 public void onEnable() {
9 if (!setupEconomy()) {
10 getLogger().severe("Vault not found! Economy features disabled.");
11 }
12 }
13
14 private boolean setupEconomy() {
15 if (getServer().getPluginManager().getPlugin("Vault") == null) return false;
16 RegisteredServiceProvider<Economy> rsp =
17 getServer().getServicesManager().getRegistration(Economy.class);
18 if (rsp == null) return false;
19 economy = rsp.getProvider();
20 return economy != null;
21 }
22
23 // Usage
24 public void chargePlayer(Player player, double amount) {
25 if (economy != null && economy.has(player, amount)) {
26 economy.withdrawPlayer(player, amount);
27 }
28 }
29}
Paper-Specific APIs
Async chunk loading
java
1// Paper: load chunk without blocking main thread
2world.getChunkAtAsync(x, z).thenAccept(chunk -> {
3 // runs on main thread after chunk loads
4 chunk.getBlock(0, 64, 0).setType(Material.GOLD_BLOCK);
5});
java
1// Set custom model data (for resource packs)
2ItemStack item = new ItemStack(Material.STICK);
3ItemMeta meta = item.getItemMeta();
4meta.setCustomModelData(1001);
5meta.displayName(Component.text("Magic Wand", NamedTextColor.LIGHT_PURPLE));
6item.setItemMeta(meta);
Player profile (async)
java
1// Paper: async profile lookup (no blocking main thread)
2Bukkit.createProfile(UUID.fromString("...")).update().thenAccept(profile -> {
3 String name = profile.getName();
4});
GriefPrevention / WorldGuard bypass
java
1// Check if location is protected (WorldGuard example)
2// Always soft-depend on protection plugins
3if (getServer().getPluginManager().getPlugin("WorldGuard") != null) {
4 // use WorldGuard API
5}
Common Tasks Checklist
Creating a new event listener
Adding a new command
Saving plugin data
Scheduling a repeating task
Build & Run
bash
1# Build plugin JAR
2./gradlew shadowJar
3
4# Output: build/libs/my-plugin-1.0.0-SNAPSHOT.jar
5# Copy to server/plugins/ and restart the server
6
7# Run Paper dev server (with run-task plugin)
8./gradlew runServer
Validator Script
Use the bundled validator before publishing a Paper plugin:
bash
1# Run from the installed skill directory:
2./scripts/validate-plugin-layout.sh --root /path/to/plugin-project
3
4# Strict mode treats warnings as failures:
5./scripts/validate-plugin-layout.sh --root /path/to/plugin-project --strict
What it checks:
plugin.yml required keys (name, version, main, api-version) and repo-supported 1.21 / positive 1.21.<patch> api-version values on the 1.21.x line, with warnings for patches newer than the repo's current example version
- Main class path exists and extends
JavaPlugin
/reload anti-pattern detection in source snippets
References