Skillshub minecraft-plugin-dev
install
source · Clone the upstream repo
git clone https://github.com/ComeOnOliver/skillshub
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/ComeOnOliver/skillshub "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/Jahrome907/minecraft-codex-skills/minecraft-plugin-dev" ~/.claude/skills/comeonoliver-skillshub-minecraft-plugin-dev && rm -rf "$T"
manifest:
skills/Jahrome907/minecraft-codex-skills/minecraft-plugin-dev/SKILL.mdsource content
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
: the target is server-side Paper/Bukkit/Spigot plugin behavior with JavaPlugin APIs.Use when
: the task requires client-side installable mods or loader APIs (Do not use when
/minecraft-modding
).minecraft-multiloader
: the task is pure vanilla datapack/command content (Do not use when
/minecraft-datapack
).minecraft-commands-scripting
Project Setup
settings.gradle.kts
settings.gradle.ktsrootProject.name = "my-plugin"
build.gradle.kts
build.gradle.ktsplugins { 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/") // For Vault (economy API) maven("https://jitpack.io") } dependencies { compileOnly("io.papermc.paper:paper-api:1.21.11-R0.1-SNAPSHOT") // Optional: Vault economy/permission integration compileOnly("com.github.MilkBowl:VaultAPI:1.7") } java { toolchain.languageVersion.set(JavaLanguageVersion.of(21)) } tasks { processResources { // Substitutes ${version} in plugin.yml with the Gradle project version filesMatching("plugin.yml") { expand("version" to project.version) } } shadowJar { archiveClassifier.set("") } build { dependsOn(shadowJar) } }
gradle/wrapper/gradle-wrapper.properties
gradle/wrapper/gradle-wrapper.propertiesdistributionUrl=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)
plugin.ymlname: 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
values. Useapi-versionwhen you target that Paper patch specifically, orapi-version: '1.21.11'only when you intentionally support the broader 1.21.x line.api-version: '1.21'
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(); // Register listeners getServer().getPluginManager().registerEvents(new PlayerListener(this), this); // Register commands 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() { return 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) { // Modify death message using Adventure components event.deathMessage( Component.text("☠ ", NamedTextColor.RED) .append(Component.text(event.getPlayer().getName(), NamedTextColor.WHITE)) .append(Component.text(" died!", NamedTextColor.RED)) ); } }
EventPriority order
LOWEST → LOW → NORMAL → HIGH → HIGHEST → MONITORUse
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; } if (args.length == 0) { player.sendMessage(Component.text("Usage: /myplugin <reload|info>", NamedTextColor.YELLOW)); return true; } return switch (args[0].toLowerCase()) { case "reload" -> { plugin.reloadConfig(); player.sendMessage(Component.text("Config reloaded.", NamedTextColor.GREEN)); yield true; } case "info" -> { player.sendMessage(Component.text("Version: " + plugin.getDescription().getVersion(), NamedTextColor.AQUA)); yield true; } default -> { player.sendMessage(Component.text("Unknown subcommand.", NamedTextColor.RED)); yield false; } }; } @Override public @Nullable List<String> onTabComplete(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, @NotNull String[] args) { if (args.length == 1) { return List.of("reload", "info").stream() .filter(s -> s.startsWith(args[0].toLowerCase())) .toList(); } return List.of(); } }
Schedulers
Synchronous (runs on main thread)
// Run once after 20 ticks (1 second) plugin.getServer().getScheduler().runTaskLater(plugin, () -> { // safe to access Bukkit API here }, 20L); // Repeating task every 40 ticks (2 seconds), starts after 0 ticks plugin.getServer().getScheduler().runTaskTimer(plugin, () -> { // runs on main thread }, 0L, 40L);
Asynchronous (for I/O / database work)
// Never touch Bukkit API in async tasks! plugin.getServer().getScheduler().runTaskAsynchronously(plugin, () -> { // safe: file I/O, HTTP requests, DB queries String data = fetchFromDatabase(); // Switch back to main thread to use Bukkit API 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(); // stop after 10 executions return; } // task logic } }.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; // Define keys (reuse instances — create once in your plugin class) NamespacedKey killKey = new NamespacedKey(plugin, "kill_count"); NamespacedKey flagKey = new NamespacedKey(plugin, "vip"); // Write player.getPersistentDataContainer().set(killKey, PersistentDataType.INTEGER, 42); player.getPersistentDataContainer().set(flagKey, PersistentDataType.BOOLEAN, true); // Read int kills = player.getPersistentDataContainer() .getOrDefault(killKey, PersistentDataType.INTEGER, 0); boolean isVip = player.getPersistentDataContainer() .getOrDefault(flagKey, PersistentDataType.BOOLEAN, false); // Check existence boolean hasData = player.getPersistentDataContainer().has(killKey, PersistentDataType.INTEGER); // Remove 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; // Simple components player.sendMessage(Component.text("Hello!", NamedTextColor.GREEN)); player.sendMessage(Component.text("Bold warning", NamedTextColor.RED, TextDecoration.BOLD)); // Compound component 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); // MiniMessage (recommended for config-driven text) import net.kyori.adventure.text.minimessage.MiniMessage; Component parsed = MiniMessage.miniMessage().deserialize( "<gradient:red:yellow>Hello World</gradient>" ); // Titles / action bars 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
src/main/resources/config.yml# Default config 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
// In onEnable(): saveDefaultConfig(); // writes config.yml if absent // Reading values int maxPlayers = getConfig().getInt("settings.max-players", 20); String message = getConfig().getString("settings.welcome-message", "Welcome!"); boolean enabled = getConfig().getBoolean("features.pvp", true); // Reloading reloadConfig(); // Writing values getConfig().set("settings.max-players", 30); saveConfig();
Custom config file
File customFile = new File(getDataFolder(), "data.yml"); if (!customFile.exists()) { saveResource("data.yml", false); // copies from resources/ } 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; } // Usage public void chargePlayer(Player player, double amount) { if (economy != null && economy.has(player, amount)) { economy.withdrawPlayer(player, amount); } } }
Paper-Specific APIs
Async chunk loading
// Paper: load chunk without blocking main thread world.getChunkAtAsync(x, z).thenAccept(chunk -> { // runs on main thread after chunk loads chunk.getBlock(0, 64, 0).setType(Material.GOLD_BLOCK); });
Custom item meta
// Set custom model data (for resource packs) 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)
// Paper: async profile lookup (no blocking main thread) Bukkit.createProfile(UUID.fromString("...")).update().thenAccept(profile -> { String name = profile.getName(); });
GriefPrevention / WorldGuard bypass
// Check if location is protected (WorldGuard example) // Always soft-depend on protection plugins if (getServer().getPluginManager().getPlugin("WorldGuard") != null) { // use WorldGuard API }
Common Tasks Checklist
Creating a new event listener
- Create class implementing
Listener - Annotate methods with
@EventHandler - Call
ingetServer().getPluginManager().registerEvents(listener, plugin)onEnable() - Add
unless you need cancelled eventsignoreCancelled = true
Adding a new command
- Define command in
underplugin.ymlcommands: - Create executor class implementing
CommandExecutor - (Optional) implement
for autocompleteTabCompleter - Register with
getCommand("name").setExecutor(new MyExecutor())
Saving plugin data
- For simple values: use
viaconfig.yml
/getConfig()saveConfig() - For per-entity data: use PDC with a
NamespacedKey - For large datasets: use async scheduler + file I/O or a database
Scheduling a repeating task
- Determine if task needs main thread (use
) or is I/O (userunTaskTimer
)runTaskTimerAsynchronously - Store the
reference so you can cancel inBukkitTaskonDisable() - Cancel all tasks in
or useonDisable()getServer().getScheduler().cancelTasks(plugin)
Build & Run
# Build plugin JAR ./gradlew shadowJar # Output: build/libs/my-plugin-1.0.0-SNAPSHOT.jar # Copy to server/plugins/ and restart the server # Run Paper dev server (with run-task plugin) ./gradlew runServer
Validator Script
Use the bundled validator before publishing a Paper plugin:
# Run from the installed skill directory (for example `.agents/skills/minecraft-plugin-dev`): ./scripts/validate-plugin-layout.sh --root /path/to/plugin-project # Strict mode treats warnings as failures: ./scripts/validate-plugin-layout.sh --root /path/to/plugin-project --strict
What it checks:
required keys (plugin.yml
,name
,version
,main
)api-version- Main class path exists and extends
JavaPlugin
anti-pattern detection in source snippets/reload
References
- Paper API Javadoc: https://jd.papermc.io/paper/1.21/
- Paper Dev Docs: https://docs.papermc.io/paper/dev/getting-started/
- Adventure (text API): https://docs.advntr.dev/
- MiniMessage format: https://docs.advntr.dev/minimessage/format.html
- Vault API: https://github.com/MilkBowl/VaultAPI
- Bukkit API Javadoc: https://javadoc.io/doc/org.bukkit/bukkit/
- run-task Gradle plugin: https://github.com/jpenilla/run-task