AI Skill Report Card

Developing Minecraft Plugins

A-85·Sep 23, 2026·Source: Web
15 / 15

Every plugin starts from this skeleton. Adapt package/class names, but keep the structure.

Java
package com.yourorg.exampleplugin; import org.bukkit.plugin.java.JavaPlugin; public final class ExamplePlugin extends JavaPlugin { private static ExamplePlugin instance; private ConfigManager configManager; @Override public void onEnable() { instance = this; // 1. Config first — everything else depends on it this.configManager = new ConfigManager(this); configManager.setup(); // 2. Version-gated integrations if (getServer().getPluginManager().getPlugin("PlaceholderAPI") != null) { new ExamplePlaceholderExpansion(this).register(); } // 3. Register listeners/commands after config+integrations exist getServer().getPluginManager().registerEvents(new PlayerListener(this), this); getCommand("example").setExecutor(new ExampleCommand(this)); getLogger().info("ExamplePlugin enabled on " + getServer().getVersion()); } @Override public void onDisable() { // Always clean up: cancel tasks, close connections, save data async-safely HandlerList.unregisterAll(this); Bukkit.getScheduler().cancelTasks(this); instance = null; } public static ExamplePlugin getInstance() { return instance; } }

plugin.yml (target the widest compatible api-version — omit or set 1.13 as floor; never assume newer-only fields exist):

YAML
name: ExamplePlugin version: '${project.version}' main: com.yourorg.exampleplugin.ExamplePlugin api-version: '1.13' softdepend: [PlaceholderAPI, LuckPerms] commands: example: description: Example command permission: exampleplugin.use permissions: exampleplugin.*: default: op children: exampleplugin.use: true exampleplugin.admin: true exampleplugin.use: default: true exampleplugin.admin: default: op
Recommendation▾
The final code block for LuckPerms integration is cut off mid-sentence — ensure the skill file is complete and doesn't truncate content
14 / 15

Progress checklist for any new plugin/feature request:

  • Confirm target platforms (Spigot/Paper/Folia) and set the lowest safe api-version
  • Design config.yml / messages.yml with full defaults, comments, and validation
  • Build a ConfigManager with hot-reload (/plugin reload) and self-healing (regenerate missing keys)
  • Implement version-abstraction layer for API differences (1.8 vs 1.13+ vs 1.20+)
  • Add LuckPerms integration via its API (soft-depend, never hard crash if absent)
  • Add PlaceholderAPI expansion class (soft-depend, register only if present)
  • Write commands with a tab-completer and permission checks
  • Write listeners with minimal work on the main thread; offload I/O/DB to async tasks
  • Add metrics/logging hooks (bStats optional, debug flag in config)
  • Stress-test mentally: plugin disable mid-tick, config with missing/invalid keys, null players, offline UUID lookups

1. Version Compatibility Strategy

Never hard-depend on classes that changed across versions. Detect and branch:

Java
public final class VersionUtil { private static final int MAJOR_MINOR = parseVersion(); private static int parseVersion() { // Bukkit.getBukkitVersion() -> "1.20.4-R0.1-SNAPSHOT" String[] parts = Bukkit.getBukkitVersion().split("-")[0].split("\\."); int major = Integer.parseInt(parts[1]); // e.g. 20 return major; } public static boolean isAtLeast(int minorVersion) { return MAJOR_MINOR >= minorVersion; } }

Use this to branch material/enum lookups (e.g., Material.matchMaterial with legacy fallback names), NBT/PersistentDataContainer usage (1.14+ only — provide metadata fallback for 1.8–1.13), and Player#sendActionBar (use reflection or spigot's sendMessage(ChatMessageType, ...) pre-1.19, Adventure API 1.19+).

For text/components, prefer a small internal MessageUtil that:

  • Uses legacy §/& color codes as the universal baseline (works everywhere).
  • Detects Adventure API (net.kyori.adventure) availability on Paper 1.16+ and uses MiniMessage when present, falling back to legacy codes otherwise.

Never use Material enum constants that were renamed/removed (e.g. Material.LOG vs Material.OAK_LOG) without a compatibility mapping table.

2. Hardcore Configuration System

Config must be self-documenting, self-repairing, and hot-reloadable. Structure:

YAML
# config.yml settings: debug: false locale: en_US check-for-updates: true storage: type: SQLITE # SQLITE | MYSQL | YAML mysql: host: localhost port: 3306 database: exampleplugin username: root password: "" pool-size: 10 use-ssl: false performance: async-io: true cache-ttl-seconds: 300 batch-save-interval-ticks: 6000 features: economy: enabled: true starting-balance: 100.0 cooldowns: enabled: true default-seconds: 30 integrations: placeholderapi: enabled: true luckperms: enabled: true sync-on-login: true messages-file: messages.yml

ConfigManager responsibilities:

Java
public final class ConfigManager { private final JavaPlugin plugin; private FileConfiguration config; private File configFile; public ConfigManager(JavaPlugin plugin) { this.plugin = plugin; } public void setup() { plugin.saveDefaultConfig(); configFile = new File(plugin.getDataFolder(), "config.yml"); config = YamlConfiguration.loadConfiguration(configFile); healMissingKeys(); validate(); } /** Merge any new keys from the bundled default into an existing user config without overwriting user values. */ private void healMissingKeys() { InputStream defStream = plugin.getResource("config.yml"); if (defStream == null) return; YamlConfiguration defaults = YamlConfiguration.loadConfiguration( new InputStreamReader(defStream, StandardCharsets.UTF_8)); boolean changed = false; for (String key : defaults.getKeys(true)) { if (!config.isSet(key)) { config.set(key, defaults.get(key)); changed = true; } } if (changed) saveQuiet(); } private void validate() { String storageType = config.getString("storage.type", "YAML").toUpperCase(); if (!Set.of("SQLITE", "MYSQL", "YAML").contains(storageType)) { plugin.getLogger().warning("Invalid storage.type '" + storageType + "', defaulting to YAML."); config.set("storage.type", "YAML"); } } public void reload() { config = YamlConfiguration.loadConfiguration(configFile); healMissingKeys(); validate(); } private void saveQuiet() { try { config.save(configFile); } catch (IOException e) { plugin.getLogger().log(Level.SEVERE, "Could not save config.yml", e); } } public FileConfiguration get() { return config; } }

Apply the same pattern (messages.yml, data.yml) — every config file gets defaults-healing + validation, never a raw getConfig() call scattered through the codebase.

3. PlaceholderAPI Integration

Java
public final class ExamplePlaceholderExpansion extends PlaceholderExpansion { private final ExamplePlugin plugin; public ExamplePlaceholderExpansion(ExamplePlugin plugin) { this.plugin = plugin; } @Override public String getIdentifier() { return "exampleplugin"; } @Override public String getAuthor() { return String.join(", ", plugin.getDescription().getAuthors()); } @Override public String getVersion() { return plugin.getDescription().getVersion(); } @Override public boolean persist() { return true; } // survive /papi reload @Override public String onPlaceholder(OfflinePlayer player, String params) { if (player == null) return ""; switch (params.toLowerCase(Locale.ROOT)) { case "balance": return String.valueOf(plugin.getEconomyManager().getBalance(player.getUniqueId())); case "rank": return plugin.getLuckPermsHook().getPrimaryGroup(player.getUniqueId()); case "cooldown_remaining": return String.valueOf(plugin.getCooldownManager().getRemainingSeconds(player.getUniqueId())); default: return null; // unknown placeholder -> null, never throw } } }

Register only if the plugin is present (softdepend in plugin.yml, runtime null-check as shown in Quick Start). Document every exposed placeholder (%exampleplugin_balance%, %exampleplugin_rank%, etc.) in the README/config comments.

4. LuckPerms Integration

Use the LuckPerms API (not permission-string guessing) for group/metadata reads, and Bukkit's permission system for actual permission checks.

Java
public final class LuckPermsHook { private final LuckPerms api; public LuckPermsHook() { RegisteredServiceProvider<LuckPerms> provider = Bukkit.getServicesManager().getRegistration(LuckPerms.class); this.api = provider != null ? provider.getProvider() : null; } public boolean isAvailable() { return api != null; } public String getPrimaryGroup(UUID uuid) { if (api == null) return "default"; User user = api.getUserManager().getUser(uuid); return user != null ? user.getPrimaryGroup() : "default"; } public CompletableFuture<Boolean> hasPermission(UUID uuid, String node) { if (api == null) return CompletableFuture.completedFuture(false); return api.getUserManager().loadUser(uuid) .thenApply(user -> user.getCachedData().getPermissionData().checkPermission(node).asBoolean()); } public String getMeta(UUID uuid, String key) { if (api == null) return null; User user = api.getUserManager().getUser(uuid); return user != null ? user.getCachedData().getMetaData().getMetaValue(key) : null; } }

Rules:

  • Always check isAvailable() / null-guard — LuckPerms is soft-depend, plugin must not crash without it.
  • Use hasPermission(...) async future
Recommendation▾
Add a section on testing strategies (unit testing with MockBukkit, or manual QA checklist) to round out completeness
0
Grade A-AI Skill Framework
Scorecard
Criteria Breakdown
Quick Start
15/15
Workflow
14/15
Examples
18/20
Completeness
17/20
Format
14/15
Conciseness
13/15