Imported from itsfizys/minecraft-dev-guide (
SKILL.md). Install upstream withnpx skills add itsfizys/minecraft-dev-guide. Copyright stays with the author.
Minecraft Development Skill
Decision Tree — What to Build First
1. Do players need to install anything on their client?
YES → Mod (Forge or Fabric)
NO → Plugin (Paper/Spigot)
2. Do you need new blocks, items, biomes, or dimensions?
YES → Mod (registry access not available in Bukkit API)
NO → Plugin is sufficient
3. Do you need to change client rendering, HUD, or shaders?
YES → Client-side mod (Fabric preferred for rendering work)
NO → Plugin or server-side mod
4. Do you need to modify vanilla mechanics (damage formulas, physics, AI)?
YES → Mod (Mixins on Fabric / Capabilities on Forge)
NO → Plugin event system is enough
When in doubt, write a plugin. Plugins deploy as a JAR drop, work with vanilla clients, and are faster to build and maintain.
Platform Quick Reference
| Platform | Purpose | API | Min Java |
|---|---|---|---|
| Paper 1.20+ | Server plugin | Bukkit/Paper API | 17 |
| Spigot | Server plugin | Bukkit API | 17 |
| Forge 1.20.1 | Client+Server mod | Forge/NMS | 17 |
| NeoForge 1.20.2+ | Client+Server mod | NeoForge (Forge fork) | 17 |
| Fabric 1.20+ | Client+Server mod | Fabric API + Mixins | 17 |
Always target Paper for plugins — API-compatible with Spigot, faster, larger modern API surface.
Build Setup
Plugin (Paper) — build.gradle.kts
plugins { java }
group = "com.yourname"
version = "1.0.0"
repositories {
maven("https://repo.papermc.io/repository/maven-public/")
}
dependencies {
// IMPORTANT: compileOnly — server already provides this at runtime. Never shade it.
compileOnly("io.papermc.paper:paper-api:1.20.4-R0.1-SNAPSHOT")
}
java { toolchain.languageVersion.set(JavaLanguageVersion.of(21)) }
Forge Mod — build.gradle (after downloading MDK from files.minecraftforge.net)
minecraft {
mappings channel: 'official', version: '1.20.1'
runs {
client { workingDirectory project.file('run') }
server { workingDirectory project.file('run') }
}
}
dependencies {
minecraft 'net.minecraftforge:forge:1.20.1-47.3.0'
}
Run ./gradlew genIntellijRuns before opening in IntelliJ.
Fabric Mod — build.gradle (from fabricmc.net/develop/template)
dependencies {
minecraft "com.mojang:minecraft:1.20.4"
mappings "net.fabricmc:yarn:1.20.4+build.3:v2"
modImplementation "net.fabricmc:fabric-loader:0.15.7"
modImplementation "net.fabricmc.fabric-api:fabric-api:0.96.4+1.20.4"
}
Run ./gradlew genSources to generate readable Yarn-mapped sources.
Plugin Manifest — plugin.yml
Required file in src/main/resources/plugin.yml. Server reads this before loading anything.
name: MyPlugin # No spaces. Shown in /plugins list and logs.
version: 1.0.0
main: com.yourname.myplugin.MyPlugin # EXACT fully-qualified class extending JavaPlugin
api-version: '1.20' # MUST be quoted — unquoted 1.20 becomes float 1.2 in YAML
description: Does cool things.
author: YourName
website: https://github.com/yourname/myplugin
depend: [Vault] # Hard deps — plugin refuses to load if any are missing
softdepend: [PlaceholderAPI, WorldGuard] # Optional — loaded before your plugin if present
commands:
mycommand:
description: Does something useful.
usage: /<command> [args]
aliases: [mc]
permission: myplugin.mycommand
permission-message: "&cNo permission."
permissions:
myplugin.*:
default: op
children:
myplugin.mycommand: true
myplugin.admin: true
myplugin.mycommand:
default: true # Everyone has this by default
myplugin.admin:
default: op # Operators only
Permission default values: true = everyone, false = nobody, op = operators only, not op = non-operators
Critical gotchas:
api-version: 1.20(unquoted) → YAML parses as float →1.2→ wrong versionmain:wrong package → plugin silently fails: "Cannot find main class"depend:a soft-optional plugin → plugin won't load if that plugin is absent
Main Class — JavaPlugin Lifecycle
public final class MyPlugin extends JavaPlugin {
private static MyPlugin instance;
@Override
public void onLoad() {
// RARELY needed. Called before worlds load.
// Only use for pre-world API registration (e.g. WorldGuard flags).
// Do NOT access worlds, players, or game objects here.
}
@Override
public void onEnable() {
instance = this;
saveDefaultConfig(); // 1. Config first — copies from JAR if not present
setupStorage(); // 2. Database / file storage
getServer().getPluginManager()
.registerEvents(new PlayerListener(this), this); // 3. Listeners
getCommand("mycommand")
.setExecutor(new MyCommand(this)); // 4. Commands
setupSoftDependencies(); // 5. Vault, PAPI, WorldGuard hooks
startScheduledTasks(); // 6. Repeating tasks last
getLogger().info("Enabled v" + getDescription().getVersion());
}
@Override
public void onDisable() {
// Close DB connections, cancel tasks, save data, remove spawned entities
getLogger().info("Disabled.");
}
public static MyPlugin getInstance() { return instance; }
}
getLogger() — always use this instead of System.out.println(). Prefixes plugin name.
getDataFolder() — returns plugins/MyPlugin/. Always write files here, never to arbitrary paths.
saveDefaultConfig() — copies config.yml from JAR to plugins/MyPlugin/config.yml. Does NOT overwrite.
saveResource("file.yml", false) — same but for any resource file.
Events & Listeners
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.setJoinMessage(null);
event.getPlayer().sendMessage("§aWelcome!");
}
// PERFORMANCE WARNING: PlayerMoveEvent fires every tick per moving player.
// At 20 players = 400+ calls/second. ALWAYS guard with block-change check.
@EventHandler
public void onMove(PlayerMoveEvent event) {
Location f = event.getFrom(), t = event.getTo();
if (f.getBlockX() == t.getBlockX()
&& f.getBlockY() == t.getBlockY()
&& f.getBlockZ() == t.getBlockZ()) return; // Same block — skip
// Only reaches here when player crosses a block boundary
handleBlockChange(event.getPlayer(), t);
}
// Async event — NEVER call Bukkit API directly. Schedule back to main thread.
@EventHandler
public void onAsyncChat(AsyncPlayerChatEvent event) {
Player p = event.getPlayer();
String msg = event.getMessage();
Bukkit.getScheduler().runTask(plugin, () -> {
// Back on main thread — all Bukkit API safe here
processMessage(p, msg);
});
}
}
Register in onEnable():
getServer().getPluginManager().registerEvents(new PlayerListener(this), this);
Event Priority Order (lowest fires first)
LOWEST → LOW → NORMAL → HIGH → HIGHEST → MONITOR
- Use
NORMALfor most cases - Use
LOWESTin protection plugins that cancel early - Use
MONITORfor read-only logging only — never cancel at MONITOR ignoreCancelled = true— skip method if another plugin already cancelled the event
Commonly Used Events
| Want to... | Event |
|---|---|
| Player joins / leaves | PlayerJoinEvent / PlayerQuitEvent |
| Block broken / placed | BlockBreakEvent / BlockPlaceEvent |
| Player chats (sync) | PlayerChatEvent |
| Player chats (async, Paper) | AsyncChatEvent (Adventure, Paper-only) |
| Player right-clicks block | PlayerInteractEvent |
| Player right-clicks entity | PlayerInteractEntityEvent |
| Inventory click | InventoryClickEvent |
| Entity dies | EntityDeathEvent |
| Entity damaged by entity | EntityDamageByEntityEvent |
| Player types a command | PlayerCommandPreprocessEvent |
| Server finishes loading | ServerLoadEvent |
Cancellable Events
@EventHandler
public void onBlockBreak(BlockBreakEvent event) {
if (isProtected(event.getBlock())) {
event.setCancelled(true); // Block won't break
event.getPlayer().sendMessage("§cProtected!");
}
}
Custom Events
public class PlayerLevelUpEvent extends Event {
private static final HandlerList HANDLERS = new HandlerList();
private final Player player;
private final int newLevel;
public PlayerLevelUpEvent(Player player, int newLevel) {
this.player = player; this.newLevel = newLevel;
}
public Player getPlayer() { return player; }
public int getNewLevel() { return newLevel; }
@Override public HandlerList getHandlers() { return HANDLERS; }
public static HandlerList getHandlerList() { return HANDLERS; } // REQUIRED static method
}
// Fire it:
Bukkit.getPluginManager().callEvent(new PlayerLevelUpEvent(player, 5));
Commands
Bukkit Style (simple, all versions)
Define in plugin.yml commands block, then:
public class MyCommand implements CommandExecutor, TabCompleter {
@Override
public boolean onCommand(CommandSender sender, Command cmd, String label, String[] args) {
// Permission check
if (!sender.hasPermission("myplugin.mycommand")) {
sender.sendMessage("§cNo permission."); return true;
}
// Player-only check
if (!(sender instanceof Player player)) {
sender.sendMessage("Players only."); return true;
}
// Argument check
if (args.length == 0) {
sender.sendMessage("§cUsage: /mycommand <player>"); return true;
}
// Logic
Player target = Bukkit.getPlayer(args[0]);
if (target == null) { sender.sendMessage("§cPlayer not found."); return true; }
doSomething(player, target);
return true; // Always return true to suppress default usage message
}
@Override
public List<String> onTabComplete(CommandSender sender, Command cmd, String alias, String[] args) {
List<String> completions = new ArrayList<>();
if (args.length == 1) {
String partial = args[0].toLowerCase();
Bukkit.getOnlinePlayers().stream()
.map(Player::getName)
.filter(n -> n.toLowerCase().startsWith(partial))
.forEach(completions::add);
}
return completions; // Never return null — return empty list instead
}
}
Register in onEnable():
PluginCommand cmd = getCommand("mycommand");
cmd.setExecutor(new MyCommand());
cmd.setTabCompleter(new MyCommand()); // Can be same instance
Paper Brigadier API (modern, Paper 1.20+, recommended for new projects)
Gives clients real-time argument hints in the / menu.
// In onEnable():
getLifecycleManager().registerEventHandler(LifecycleEvents.COMMANDS, event -> {
Commands commands = event.registrar();
commands.register(
Commands.literal("mycommand")
.requires(src -> src.getSender().hasPermission("myplugin.mycommand"))
.then(Commands.argument("target", ArgumentTypes.player())
.executes(ctx -> {
CommandSourceStack src = ctx.getSource();
List<Player> targets = ctx.getArgument("target",
PlayerSelectorArgumentResolver.class).resolve(src);
// handle
return Command.SINGLE_SUCCESS;
}))
.then(Commands.literal("reload")
.requires(s -> s.getSender().hasPermission("myplugin.reload"))
.executes(ctx -> { reloadPlugin(); return 1; }))
.build(),
"My command description.",
List.of("mc") // aliases
);
});
Brigadier argument types:
| Class | Input Example |
|---|---|
StringArgumentType.word() |
Steve |
StringArgumentType.greedyString() |
Hello world, no quotes needed |
IntegerArgumentType.integer(min, max) |
42 |
FloatArgumentType.floatArg() |
3.14 |
BoolArgumentType.bool() |
true |
ArgumentTypes.player() |
Steve (with player selector) |
ArgumentTypes.world() |
world_nether |
ArgumentTypes.blockState() |
minecraft:stone |
Configuration API
// onEnable() — always call before reading any config values
saveDefaultConfig(); // Copies config.yml from JAR. Never overwrites existing file.
// Reading — always provide fallback defaults
int delay = getConfig().getInt("settings.delay", 3);
String msg = getConfig().getString("messages.welcome", "Welcome!");
boolean flag = getConfig().getBoolean("features.pvp", true);
double mult = getConfig().getDouble("economy.multiplier", 1.0);
List<String> bl = getConfig().getStringList("blocked-commands"); // Never null
// Writing and persisting
getConfig().set("players." + uuid + ".balance", 500.0);
saveConfig(); // Writes memory state to disk
// Reloading from disk (e.g. /plugin reload command)
reloadConfig();
// Custom config files
File file = new File(getDataFolder(), "data.yml");
if (!file.exists()) saveResource("data.yml", false);
FileConfiguration data = YamlConfiguration.loadConfiguration(file);
data.set("key", "value");
try { data.save(file); } catch (IOException e) { getLogger().severe("Save failed"); }
Cache values in a manager class — don't re-read from config on every access:
public class ConfigManager {
private int maxHomes;
private String prefix;
public ConfigManager(MyPlugin plugin) {
plugin.saveDefaultConfig();
reload(plugin);
}
public void reload(MyPlugin plugin) {
plugin.reloadConfig();
maxHomes = plugin.getConfig().getInt("max-homes", 5);
prefix = plugin.getConfig().getString("prefix", "&6[Plugin]");
}
public int getMaxHomes() { return maxHomes; }
public String getPrefix() { return prefix; }
}
Scheduling
20 ticks = 1 second | 100 ticks = 5s | 1200 ticks = 1 minute
BukkitScheduler sched = Bukkit.getScheduler();
// Run once on main thread after delay
sched.runTaskLater(plugin, () -> {
player.sendMessage("5 seconds later!");
}, 100L); // ticks
// Repeating on main thread: start after 0, repeat every 10s
int taskId = sched.scheduleSyncRepeatingTask(plugin, () -> {
broadcastStats();
}, 0L, 200L);
sched.cancelTask(taskId); // Cancel by ID
// BukkitRunnable — self-cancelling, preferred for stateful tasks
new BukkitRunnable() {
int count = 5;
@Override public void run() {
if (count-- <= 0) { cancel(); return; }
player.sendMessage("§e" + count + "...");
}
}.runTaskTimer(plugin, 0L, 20L);
// Async — for I/O only. NO Bukkit API here.
sched.runTaskAsynchronously(plugin, () -> {
String result = database.query(uuid.toString()); // Safe: database I/O
// Must return to main thread before using Bukkit API:
sched.runTask(plugin, () -> {
player.sendMessage("Balance: " + result); // Safe: main thread
});
});
// Async repeating (e.g. auto-save every 5 minutes)
sched.runTaskTimerAsynchronously(plugin, () -> database.saveAll(), 6000L, 6000L);
// Paper async scheduler (real-time, not tick-based)
plugin.getServer().getAsyncScheduler().runAtFixedRate(plugin,
task -> database.saveAll(), 1, 60, TimeUnit.SECONDS);
Thread safety rules:
| Operation | Safe Thread |
|---|---|
| Database queries, file I/O, HTTP | Async |
player.sendMessage(), teleport() |
Main only |
world.getBlockAt() / setType() |
Main only |
Bukkit.getOnlinePlayers() |
Main only |
event.setCancelled() |
Same thread as event |
Inventory GUIs
// Create (size must be multiple of 9: 9/18/27/36/45/54)
Inventory gui = Bukkit.createInventory(null, 27, "§6My Shop");
// Build an item
ItemStack item = new ItemStack(Material.DIAMOND_SWORD);
ItemMeta meta = item.getItemMeta();
meta.setDisplayName("§bFrost Blade");
meta.setLore(List.of("§7Deals frost damage", "", "§eDamage: +12"));
meta.addItemFlags(ItemFlag.HIDE_ATTRIBUTES, ItemFlag.HIDE_ENCHANTS);
meta.setCustomModelData(10001); // For resource pack texture overrides
item.setItemMeta(meta);
gui.setItem(13, item); // Slot 13 = center of 3-row chest
// Fill empty slots with filler
ItemStack filler = new ItemStack(Material.GRAY_STAINED_GLASS_PANE);
ItemMeta fm = filler.getItemMeta(); fm.setDisplayName(" "); filler.setItemMeta(fm);
for (int i = 0; i < gui.getSize(); i++)
if (gui.getItem(i) == null) gui.setItem(i, filler);
player.openInventory(gui);
Slot layout (27-slot / 3-row chest):
0 1 2 3 4 5 6 7 8
9 10 11 12 13 14 15 16 17
18 19 20 21 22 23 24 25 26
Click handling — ALWAYS cancel to prevent item theft:
@EventHandler
public void onInventoryClick(InventoryClickEvent event) {
if (!event.getView().getTitle().equals("§6My Shop")) return;
event.setCancelled(true); // REQUIRED — prevents item extraction
if (event.getCurrentItem() == null) return;
if (event.getCurrentItem().getType() == Material.GRAY_STAINED_GLASS_PANE) return;
Player player = (Player) event.getWhoClicked();
switch (event.getSlot()) {
case 11 -> openSubMenu(player);
case 13 -> handlePurchase(player, event.getCurrentItem());
}
}
@EventHandler
public void onInventoryDrag(InventoryDragEvent event) {
if (!event.getView().getTitle().equals("§6My Shop")) return;
event.setCancelled(true); // Also required — drag can bypass click cancel
}
InventoryHolder pattern (preferred over title string matching):
public class ShopGUI implements InventoryHolder {
private final Inventory inventory;
public ShopGUI() {
this.inventory = Bukkit.createInventory(this, 54, "Shop");
populate();
}
@Override public Inventory getInventory() { return inventory; }
private void populate() { /* fill items */ }
}
// In listener — no fragile title string comparison:
@EventHandler
public void onInventoryClick(InventoryClickEvent event) {
if (!(event.getInventory().getHolder() instanceof ShopGUI shop)) return;
event.setCancelled(true);
// handle...
}
Persistent Data Container (PDC)
Store custom data on any entity, item, block, chunk, or world. Saved to disk automatically.
// Create keys once and reuse (make static constants)
NamespacedKey LEVEL_KEY = new NamespacedKey(plugin, "player-level");
NamespacedKey TYPE_KEY = new NamespacedKey(plugin, "item-type");
NamespacedKey COOLDOWN_KEY = new NamespacedKey(plugin, "last-attack");
// --- Player data ---
PersistentDataContainer pdc = player.getPersistentDataContainer();
// Write
pdc.set(LEVEL_KEY, PersistentDataType.INTEGER, 5);
pdc.set(COOLDOWN_KEY, PersistentDataType.LONG, System.currentTimeMillis());
pdc.set(new NamespacedKey(plugin, "name"), PersistentDataType.STRING, "warrior");
// Read (always use getOrDefault)
int level = pdc.getOrDefault(LEVEL_KEY, PersistentDataType.INTEGER, 1);
long lastHit = pdc.getOrDefault(COOLDOWN_KEY, PersistentDataType.LONG, 0L);
boolean has = pdc.has(LEVEL_KEY, PersistentDataType.INTEGER);
pdc.remove(LEVEL_KEY); // Delete
// --- Item data (via ItemMeta) ---
ItemMeta meta = item.getItemMeta();
meta.getPersistentDataContainer().set(TYPE_KEY, PersistentDataType.STRING, "frost-sword");
item.setItemMeta(meta);
// Identify a custom item
public static boolean isFrostSword(ItemStack item, NamespacedKey key) {
if (item == null || !item.hasItemMeta()) return false;
return "frost-sword".equals(item.getItemMeta().getPersistentDataContainer()
.get(key, PersistentDataType.STRING));
}
Available types: BYTE, SHORT, INTEGER, LONG, FLOAT, DOUBLE, STRING, BYTE_ARRAY, INTEGER_ARRAY, LONG_ARRAY, TAG_CONTAINER (nested PDC)
Complex objects: serialize to JSON string, store as STRING:
String json = new Gson().toJson(myObject);
pdc.set(key, PersistentDataType.STRING, json);
MyObject loaded = new Gson().fromJson(pdc.get(key, PersistentDataType.STRING), MyObject.class);
When to use PDC vs database:
- PDC ✅ — per-item tags, per-entity flags, per-player small state (≤ a few keys)
- Database ✅ — leaderboards, cross-player queries, large volumes, offline player data
Holograms
TextDisplay Entity (Paper 1.19.4+ — PREFERRED)
TextDisplay display = location.getWorld().spawn(location, TextDisplay.class, d -> {
d.text(Component.text("§aServer Online").append(Component.newline())
.append(Component.text("Players: 42").color(NamedTextColor.YELLOW)));
d.setBillboard(Display.Billboard.CENTER); // Always faces camera
d.setBackgroundColor(Color.fromARGB(0, 0, 0, 0)); // Transparent bg
d.setShadowed(true);
d.setAlignment(TextDisplay.TextAlignment.CENTER);
d.setPersistent(true); // Survives chunk unloads
});
display.text(Component.text("Updated text")); // Update
display.remove(); // Remove
// Multi-line: stack multiple TextDisplays 0.3 apart on Y
public static List<TextDisplay> spawnMultiLine(Location base, List<String> lines) {
List<TextDisplay> result = new ArrayList<>();
for (int i = 0; i < lines.size(); i++) {
Location loc = base.clone().add(0, (lines.size() - 1 - i) * 0.3, 0);
result.add(loc.getWorld().spawn(loc, TextDisplay.class, d -> {
d.text(Component.text(lines.get(i)));
d.setBillboard(Display.Billboard.CENTER);
d.setBackgroundColor(Color.fromARGB(0, 0, 0, 0));
d.setPersistent(true);
}));
}
return result;
}
ArmorStand Method (all versions, fallback)
ArmorStand stand = (ArmorStand) world.spawnEntity(
location.clone().subtract(0, 1.5, 0), EntityType.ARMOR_STAND);
stand.setCustomName("§aFloating Text");
stand.setCustomNameVisible(true);
stand.setVisible(false); // Hide the armor stand body
stand.setGravity(false);
stand.setInvulnerable(true);
stand.setSmall(true);
stand.setMarker(true); // No collision, no hitbox interaction
Always clean up in onDisable():
private final List<TextDisplay> activeDisplays = new ArrayList<>();
// Add displays to this list when spawning
// In onDisable():
activeDisplays.stream().filter(Entity::isValid).forEach(Entity::remove);
Adventure API (Text, Sound, Titles — Paper)
Replace all § codes with Adventure on Paper 1.20+.
// MiniMessage — best for config-defined strings
MiniMessage mm = MiniMessage.miniMessage();
Component msg = mm.deserialize("<gold><bold>ANNOUNCEMENT</bold></gold> <white>Server restarting!</white>");
player.sendMessage(msg);
// Tags: <red> <gold> <aqua> <white> <gray> <dark_gray> <green> <yellow>
// <#FF5733> (hex)
// <bold> <italic> <underlined> <strikethrough> <obfuscated>
// <gradient:red:gold> <rainbow>
// <click:run_command:/shop> <click:open_url:https://...>
// <hover:show_text:'tooltip here'>
// <newline>
// Component builder (programmatic)
Component c = Component.text("Click to buy!")
.color(NamedTextColor.AQUA)
.decorate(TextDecoration.UNDERLINED)
.clickEvent(ClickEvent.runCommand("/shop"))
.hoverEvent(HoverEvent.showText(Component.text("Opens the shop")));
player.sendMessage(c);
// Action bar (above hotbar)
player.sendActionBar(Component.text("§aYou are in a safe zone"));
// Title (center screen)
player.showTitle(Title.title(
Component.text("GAME START").color(NamedTextColor.GOLD),
Component.text("Good luck!").color(NamedTextColor.YELLOW),
Title.Times.times(Duration.ofMillis(500), Duration.ofSeconds(3), Duration.ofMillis(500))
));
player.clearTitle();
// Sound
player.playSound(Sound.sound(
Key.key("minecraft:entity.player.levelup"), Sound.Source.MASTER, 1.0f, 1.0f));
// Async paper chat event (replacement for deprecated AsyncPlayerChatEvent)
@EventHandler
public void onChat(AsyncChatEvent event) {
Component original = event.message();
event.message(Component.text("[VIP] ").color(NamedTextColor.GOLD).append(original));
}
NMS & Internals
Only reach for NMS when the Bukkit API genuinely can't do what you need.
paperweight-userdev (cleanest NMS access, stable package names):
// build.gradle.kts
plugins { id("io.papermc.paperweight.userdev") version "1.7.1" }
dependencies { paperweight.paperDevBundle("1.20.4-R0.1-SNAPSHOT") }
import net.minecraft.server.level.ServerPlayer;
ServerPlayer nmsPlayer = ((CraftPlayer) player).getHandle();
nmsPlayer.connection.send(somePacket);
ProtocolLib (for packet interception — handles version differences):
# plugin.yml: depend: [ProtocolLib]
ProtocolManager pm = ProtocolLibrary.getProtocolManager();
// Intercept incoming packets (client → server)
pm.addPacketListener(new PacketAdapter(plugin, ListenerPriority.NORMAL,
PacketType.Play.Client.USE_ENTITY) {
@Override public void onPacketReceiving(PacketEvent event) {
int entityId = event.getPacket().getIntegers().read(0);
// react to entity interaction
}
});
// Intercept outgoing packets (server → client)
pm.addPacketListener(new PacketAdapter(plugin, ListenerPriority.NORMAL,
PacketType.Play.Server.CHAT) {
@Override public void onPacketSending(PacketEvent event) {
// modify before it reaches client
}
});
What actually requires NMS:
| Feature | Needs NMS? |
|---|---|
| Custom packets | Yes (or ProtocolLib) |
| Fake/per-player entities | Yes (or ProtocolLib) |
| TextDisplay holograms | ❌ Native Paper 1.19.4+ API |
| NBT manipulation | ❌ Use PDC |
| Scoreboard / boss bar | ❌ Bukkit API |
| Custom chat JSON | ❌ Adventure API |
| Async teleport | ❌ player.teleportAsync(dest) (Paper) |
Soft Dependency Hooks
Vault (Economy + Permissions)
// plugin.yml: softdepend: [Vault]
private Economy economy;
private boolean setupVault() {
if (!getServer().getPluginManager().isPluginEnabled("Vault")) return false;
RegisteredServiceProvider<Economy> rsp =
getServer().getServicesManager().getRegistration(Economy.class);
if (rsp == null) return false;
economy = rsp.getProvider();
return economy != null;
}
// Use:
double bal = economy.getBalance(player);
boolean hasEnough = economy.has(player, 100.0);
EconomyResponse r = economy.withdrawPlayer(player, 100.0);
if (!r.transactionSuccess()) { /* handle failure */ }
economy.depositPlayer(player, 50.0);
String formatted = economy.format(1234.5); // "1,234.50 Coins"
PlaceholderAPI
// plugin.yml: softdepend: [PlaceholderAPI]
// Register your own placeholders (%myplugin_balance%)
if (Bukkit.getPluginManager().isPluginEnabled("PlaceholderAPI")) {
new PlaceholderExpansion() {
public String getIdentifier() { return "myplugin"; }
public String getAuthor() { return "YourName"; }
public String getVersion() { return "1.0"; }
public boolean persist() { return true; }
public String onPlaceholderRequest(Player p, String params) {
return switch (params) {
case "balance" -> economy.format(economy.getBalance(p));
case "level" -> String.valueOf(getPlayerLevel(p));
default -> null;
};
}
}.register();
}
// Use PAPI placeholders in strings from your plugin
String raw = config.getString("welcome-message");
if (Bukkit.getPluginManager().isPluginEnabled("PlaceholderAPI"))
raw = PlaceholderAPI.setPlaceholders(player, raw);
player.sendMessage(ChatColor.translateAlternateColorCodes('&', raw));
WorldGuard
// plugin.yml: depend: [WorldEdit], softdepend: [WorldGuard]
private boolean canBuild(Player player, Location location) {
if (!Bukkit.getPluginManager().isPluginEnabled("WorldGuard")) return true;
LocalPlayer lp = WorldGuardPlugin.inst().wrapPlayer(player);
RegionContainer rc = WorldGuard.getInstance().getPlatform().getRegionContainer();
return rc.createQuery().testState(BukkitAdapter.adapt(location), lp, Flags.BUILD);
}
Forge Mod — Core Patterns
mods.toml
modLoader="javafml"
loaderVersion="[47,)"
license="MIT"
[[mods]]
modId="mymod"
version="1.0.0"
displayName="My Mod"
description="Does cool things."
authors="YourName"
[[dependencies.mymod]]
modId="forge"; mandatory=true; versionRange="[47.3.0,)"
ordering="NONE"; side="BOTH"
[[dependencies.mymod]]
modId="minecraft"; mandatory=true; versionRange="[1.20.1,1.21)"
ordering="NONE"; side="BOTH"
Main Mod Class
@Mod("mymod")
public class MyMod {
public static final String MOD_ID = "mymod";
public static final Logger LOGGER = LogManager.getLogger();
public MyMod() {
IEventBus modBus = FMLJavaModLoadingContext.get().getModEventBus();
// Register DeferredRegisters on the MOD bus
ModBlocks.BLOCKS.register(modBus);
ModItems.ITEMS.register(modBus);
// Lifecycle events on MOD bus
modBus.addListener(this::commonSetup);
modBus.addListener(this::clientSetup);
// Game events on FORGE bus
MinecraftForge.EVENT_BUS.register(this);
}
private void commonSetup(FMLCommonSetupEvent event) {
// Both sides: capability registration, brewing recipes, etc.
}
private void clientSetup(FMLClientSetupEvent event) {
// Client only: screen factories, key bindings, renderers
}
@SubscribeEvent
public void onPlayerLogin(PlayerEvent.PlayerLoggedInEvent event) {
LOGGER.info("Player joined: " + event.getEntity().getName().getString());
}
}
THE #1 FORGE CONFUSION — Two Event Buses:
| Bus | Register With | Events |
|---|---|---|
modEventBus (FML) |
In mod constructor | FMLCommonSetupEvent, RegisterEvent, EntityAttributeCreationEvent |
MinecraftForge.EVENT_BUS |
Anywhere | PlayerEvent, BlockEvent, EntityEvent, ServerLifecycleEvents |
DeferredRegister (blocks, items, entities)
public class ModItems {
public static final DeferredRegister<Item> ITEMS =
DeferredRegister.create(ForgeRegistries.ITEMS, MyMod.MOD_ID);
public static final RegistryObject<Item> FROST_SHARD = ITEMS.register("frost_shard",
() -> new Item(new Item.Properties().stacksTo(64)));
public static final RegistryObject<Item> MAGIC_BERRY = ITEMS.register("magic_berry",
() -> new Item(new Item.Properties()
.food(new FoodProperties.Builder()
.nutrition(4).saturationMod(0.6F).alwaysEat().build())));
}
// In mod constructor: ModItems.ITEMS.register(modEventBus);
// Access: ModItems.FROST_SHARD.get() — only AFTER FMLCommonSetupEvent fires
public class ModBlocks {
public static final DeferredRegister<Block> BLOCKS =
DeferredRegister.create(ForgeRegistries.BLOCKS, MyMod.MOD_ID);
public static final RegistryObject<Block> FROST_BLOCK = BLOCKS.register("frost_block",
() -> new Block(BlockBehaviour.Properties.of()
.strength(1.5F, 6.0F).sound(SoundType.GLASS).requiresCorrectToolForDrops()));
}
Side Safety (critical)
// WRONG — crashes on dedicated server
private void commonSetup(FMLCommonSetupEvent e) {
Minecraft.getInstance(); // CLIENT ONLY
}
// CORRECT
private void clientSetup(FMLClientSetupEvent e) {
Minecraft.getInstance(); // Safe — only runs on client
}
// OR use DistExecutor anywhere
DistExecutor.unsafeRunWhenOn(Dist.CLIENT, () -> () -> {
Minecraft.getInstance(); // Safe
});
Forge Networking
// Channel — declare once
public static final SimpleChannel CHANNEL = NetworkRegistry.newSimpleChannel(
new ResourceLocation(MOD_ID, "main"), () -> "1", "1"::equals, "1"::equals);
// Packet class
public class SyncDataPacket {
private final int level;
public SyncDataPacket(int level) { this.level = level; }
public SyncDataPacket(FriendlyByteBuf buf) { this.level = buf.readInt(); }
public void encode(FriendlyByteBuf buf) { buf.writeInt(level); }
public void handle(NetworkEvent.Context ctx) {
ctx.enqueueWork(() -> {
// Main game thread — safe to access game state
if (ctx.getDirection() == NetworkDirection.PLAY_TO_CLIENT) {
ClientDataHolder.setLevel(level); // client side
}
});
ctx.setPacketHandled(true); // REQUIRED — always call this
}
}
// Register (in FMLCommonSetupEvent)
CHANNEL.messageBuilder(SyncDataPacket.class, 0, NetworkDirection.PLAY_TO_CLIENT)
.encoder(SyncDataPacket::encode)
.decoder(SyncDataPacket::new)
.consumerMainThread(SyncDataPacket::handle)
.add();
// Send
CHANNEL.send(PacketDistributor.PLAYER.with(() -> serverPlayer), new SyncDataPacket(5));
CHANNEL.send(PacketDistributor.ALL.noArg(), new SyncDataPacket(5));
CHANNEL.sendToServer(new SyncDataPacket(5)); // Client → server
// Security: ALWAYS validate client→server packet data
public void handle(NetworkEvent.Context ctx) {
ctx.enqueueWork(() -> {
ServerPlayer sender = ctx.getSender();
if (sender == null) return;
if (level < 0 || level > 100) return; // Validate bounds
// process...
});
ctx.setPacketHandled(true);
}
Language file
src/main/resources/assets/mymod/lang/en_us.json:
{
"block.mymod.frost_block": "Frost Block",
"item.mymod.frost_shard": "Frost Shard",
"entity.mymod.frost_golem": "Frost Golem",
"itemGroup.mymod": "My Mod"
}
Fabric Mod — Core Patterns
fabric.mod.json
{
"schemaVersion": 1,
"id": "mymod",
"version": "1.0.0",
"name": "My Mod",
"description": "Does cool things.",
"authors": ["YourName"],
"environment": "*",
"entrypoints": {
"main": ["com.yourname.mymod.MyMod"],
"client": ["com.yourname.mymod.client.MyModClient"]
},
"mixins": ["mymod.mixins.json"],
"depends": {
"fabricloader": ">=0.15.0",
"fabric-api": "*",
"minecraft": "~1.20.4",
"java": ">=17"
}
}
Environment values: "*" = both sides, "client" = client only, "server" = dedicated server only
Entrypoints
// main entrypoint — runs on BOTH client and dedicated server
public class MyMod implements ModInitializer {
public static final String MOD_ID = "mymod";
@Override
public void onInitialize() {
// Register items, blocks, server-side events here
Registry.register(Registries.ITEM, Identifier.of(MOD_ID, "frost_shard"),
new Item(new Item.Settings().maxCount(64)));
ServerPlayConnectionEvents.JOIN.register((handler, sender, server) ->
handler.player.sendMessage(Text.literal("Welcome!"), false));
}
}
// client entrypoint — ONLY runs on client. NEVER put server-compatible code here.
@Environment(EnvType.CLIENT)
public class MyModClient implements ClientModInitializer {
@Override
public void onInitializeClient() {
// Key bindings, renderers, HUD, screen factories
HudRenderCallback.EVENT.register((drawContext, tickDelta) -> {
MinecraftClient mc = MinecraftClient.getInstance();
drawContext.drawText(mc.textRenderer, "Custom HUD", 10, 10, 0xFFFFFF, true);
});
}
}
Fabric Event Callbacks
// Server lifecycle
ServerLifecycleEvents.SERVER_STARTED.register(server -> { /* server up */ });
ServerLifecycleEvents.SERVER_STOPPING.register(server -> saveAll(server));
// Per-tick
ServerTickEvents.END_SERVER_TICK.register(server -> {
if (server.getTicks() % 20 == 0) doPeriodicCheck(server);
});
// Block interaction
UseBlockCallback.EVENT.register((player, world, hand, hitResult) -> {
// Return ActionResult.PASS to let vanilla handle, FAIL to cancel, SUCCESS to consume
return ActionResult.PASS;
});
// Entity attack
AttackEntityCallback.EVENT.register((player, world, hand, entity, hitResult) -> {
if (!world.isClient && entity instanceof ZombieEntity zombie)
zombie.addStatusEffect(new StatusEffectInstance(StatusEffects.SLOWNESS, 100, 2));
return ActionResult.PASS;
});
// Entity loads
ServerEntityEvents.ENTITY_LOAD.register((entity, world) -> { /* entity appeared */ });
Fabric Mixins
mymod.mixins.json
{
"required": true,
"minVersion": "0.8",
"package": "com.yourname.mymod.mixin",
"compatibilityLevel": "JAVA_17",
"mixins": ["ServerPlayerEntityMixin"],
"client": ["GameRendererMixin"],
"injectors": { "defaultRequire": 1 }
}
Critical: client-only Mixin classes go in "client" array, not "mixins". Wrong array = dedicated server crash.
Mixin Annotations
@Mixin(ServerPlayerEntity.class)
public class ServerPlayerEntityMixin {
// @Inject — insert code at a point (most compatible, use this first)
@Inject(method = "onDeath", at = @At("HEAD"))
private void onDeathHead(DamageSource source, CallbackInfo ci) {
// Runs before vanilla death logic
}
// @Inject with cancel — prevent vanilla behavior
@Inject(method = "attack", at = @At("HEAD"), cancellable = true)
private void preventAttack(Entity target, CallbackInfo ci) {
if (inSafeZone()) ci.cancel(); // Aborts vanilla method
}
// @Inject on RETURN — inspect/override return value
@Inject(method = "isInvulnerableTo", at = @At("RETURN"), cancellable = true)
private void modifyInvulnerable(DamageSource src, CallbackInfoReturnable<Boolean> cir) {
if (src.isOf(DamageTypes.FALLING_ANVIL)) cir.setReturnValue(true);
}
// @Shadow — access private vanilla fields or methods
@Shadow private int experienceLevel;
@Shadow protected abstract void dropInventory();
// @Unique — add new fields/methods to the class
@Unique private int mymod_counter = 0; // ALWAYS prefix with modid
// @ModifyArg — change one argument to a method call inside this method
@ModifyArg(
method = "damage",
at = @At(value = "INVOKE",
target = "Lnet/minecraft/entity/LivingEntity;applyDamage(Lnet/minecraft/entity/damage/DamageSource;F)V"),
index = 1)
private float halveIncomingDamage(float original) { return original * 0.5f; }
// @Overwrite — replaces entire method (AVOID — breaks other mods)
/** @author YourName @reason Short explanation required */
@Overwrite
public void someMethod() { /* full replacement */ }
}
@At targets:
| Target | When it fires |
|---|---|
@At("HEAD") |
Start of method |
@At("RETURN") |
Before each return |
@At("TAIL") |
Before final return |
@At(value="INVOKE", target="Lpackage/Class;method(desc)V") |
Before a specific method call |
Method descriptor cheat sheet:
| Descriptor | Java type |
|---|---|
V |
void |
Z |
boolean |
I |
int |
J |
long |
F |
float |
D |
double |
Ljava/lang/String; |
String |
[I |
int[] |
Example full descriptor: "damage(Lnet/minecraft/entity/damage/DamageSource;F)Z"
Rules:
- Prefer
@Inject+cancellable = trueover@Overwrite - Prefix all
@Uniquemembers with your mod ID - Never put client code in
"mixins"JSON array — use"client"array
OpenGL & Minecraft Rendering
Minecraft uses OpenGL 3.2 Core Profile via LWJGL3. All GL calls must be on the render thread.
RenderSystem.assertOnRenderThread(); // Assert at start of GL blocks
PoseStack — transformation matrix:
poseStack.pushPose();
poseStack.translate(x, y, z);
poseStack.mulPose(Axis.YP.rotationDegrees(angle)); // Rotate around Y
poseStack.scale(sx, sy, sz);
// draw here
poseStack.popPose();
Custom BlockEntityRenderer:
public class PedestalRenderer implements BlockEntityRenderer<PedestalBlockEntity> {
@Override
public void render(PedestalBlockEntity be, float partialTick, PoseStack ps,
MultiBufferSource buffers, int light, int overlay) {
ps.pushPose();
ps.translate(0.5, 1.2, 0.5);
ps.mulPose(Axis.YP.rotationDegrees(be.getLevel().getGameTime() * 2));
ItemStack item = be.getDisplayItem();
if (!item.isEmpty())
Minecraft.getInstance().getItemRenderer().renderStatic(
item, ItemDisplayContext.GROUND, light, overlay, ps, buffers, null, 0);
ps.popPose();
}
}
// Register in client setup:
// BlockEntityRenderers.register(ModBlockEntities.PEDESTAL, PedestalRenderer::new);
Render types: RenderType.solid(), RenderType.cutout(), RenderType.cutoutMipped(), RenderType.translucent(), RenderType.glint(), RenderType.eyes()
Render pipeline passes (per frame):
- Solid (opaque blocks)
- Cutout (leaves, torches, glass panes)
- Translucent (water, stained glass)
- Entities
- Block entities
- GUI / HUD
HUD rendering (Fabric):
HudRenderCallback.EVENT.register((ctx, tickDelta) -> {
ctx.drawText(client.textRenderer, "§aHello", 10, 10, 0xFFFFFF, true);
ctx.fill(10, 25, 110, 35, 0x80000000); // x1,y1,x2,y2,ARGB
ctx.drawTexture(MY_TEXTURE, 10, 40, 0, 0, 32, 32, 32, 32);
});
GLSL Shaders
Vanilla Core Shader Override (Resource Pack)
Place in assets/minecraft/shaders/core/<original_name>.fsh / .vsh.
Standard uniforms from vanilla:
| Uniform | Type | Description |
|---|---|---|
ModelViewMat |
mat4 |
Model-view transform |
ProjMat |
mat4 |
Projection matrix |
Sampler0 |
sampler2D |
Main texture |
Sampler2 |
sampler2D |
Lightmap |
GameTime |
float |
Game time (for animation) |
FogStart, FogEnd, FogColor |
float/vec4 |
Distance fog |
ColorModulator |
vec4 |
Global color tint |
ScreenSize |
vec2 |
Screen resolution |
Minimal solid block fragment shader:
#version 150
uniform sampler2D Sampler0;
uniform sampler2D Sampler2;
uniform float GameTime;
in vec2 texCoord;
in vec4 vertexColor;
in vec2 texCoord1;
out vec4 fragColor;
void main() {
vec4 color = texture(Sampler0, texCoord);
if (color.a < 0.1) discard;
vec4 light = texture(Sampler2, texCoord1);
fragColor = color * vertexColor * light;
}
Minimal solid block vertex shader:
#version 150
in vec3 Position;
in vec4 Color;
in vec2 UV0;
in vec2 UV2;
in vec3 Normal;
uniform mat4 ModelViewMat;
uniform mat4 ProjMat;
out vec4 vertexColor;
out vec2 texCoord;
out vec2 texCoord1;
void main() {
gl_Position = ProjMat * ModelViewMat * vec4(Position, 1.0);
vertexColor = Color;
texCoord = UV0;
texCoord1 = UV2 / 256.0;
}
Iris Shader Pack Structure
shaderpacks/MyShader/shaders/
├── shaders.properties ← config: shadow res, render targets, etc.
├── gbuffers_terrain.vsh / .fsh ← Opaque world geometry
├── gbuffers_entities.vsh / .fsh
├── gbuffers_water.vsh / .fsh ← Translucent geometry
├── gbuffers_skytextured.vsh / .fsh
├── composite.vsh / .fsh ← Post-process pass 1 (SSAO, shadows, lighting)
├── composite1.vsh / .fsh ← Pass 2 (bloom, reflections)
└── final.vsh / .fsh ← Tone mapping → screen output
Iris additional uniforms:
| Uniform | Type | Description |
|---|---|---|
frameCounter |
int |
Frame index |
cameraPosition |
vec3 |
Camera world position |
sunPosition |
vec3 |
Sun direction vector |
shadowProjection |
mat4 |
Shadow map projection |
shadowModelView |
mat4 |
Shadow map view |
rainStrength |
float |
0.0–1.0 rain intensity |
isEyeInWater |
int |
1=water, 2=lava |
nightVision |
float |
Night vision strength |
GLSL built-in functions:
mix(a, b, t) // LERP between a and b by t
clamp(x, lo, hi) // Clamp x to [lo, hi]
smoothstep(lo, hi, x) // Smooth Hermite interpolation
normalize(v) // Unit vector
length(v) // Vector magnitude
dot(a, b) // Dot product
cross(a, b) // Cross product (vec3 only)
texture(sampler, uv) // Sample a texture
sin(x), cos(x), atan(y, x)
pow(x, n), sqrt(x), abs(x), floor(x), ceil(x)
Vulkan & Modern Rendering Context
Minecraft Java Edition uses OpenGL via LWJGL3, not Vulkan natively. Vulkan bindings exist in LWJGL3 but no mod has replaced Minecraft's renderer with Vulkan — it would require wrapping all of Minecraft's GL calls.
Sodium (by CaffeineMC) is the closest thing — it rebuilds the OpenGL renderer to be Vulkan-efficient:
| Approach | Draw calls/frame | Why |
|---|---|---|
| Vanilla 1.20 | 2,000–8,000 | One draw call per chunk section per render layer |
| Sodium | 10–50 | glMultiDrawElementsIndirect — GPU issues its own draw list |
What Sodium does: chunks packed into large region VBOs, async geometry baking off the render thread, frustum + occlusion culling, single indirect draw call per pass.
Vulkan key concepts (for context, not direct use):
VkDevice— logical GPU handle with graphics/compute/transfer queuesVkCommandBuffer— records draw calls, can be built in parallel on multiple threadsVkRenderPass— describes attachments (which textures to write to) and subpass ordering- Memory is explicit: allocate
VkDeviceMemory, bind to buffer, map for CPU writes, unmap before GPU reads - No global state — all state is local to the pipeline object
Common Mistakes & Exact Fixes
| Mistake | Fix |
|---|---|
api-version: 1.20 unquoted |
api-version: '1.20' — YAML treats unquoted as float 1.2 |
Wrong main: class path in plugin.yml |
Must be EXACT fully-qualified name of the JavaPlugin subclass |
| Shading Paper/Forge/Fabric API into JAR | Use compileOnly / provided — server already provides it |
| Bukkit API called from async thread | Wrap with Bukkit.getScheduler().runTask(plugin, () -> {...}) |
| NPE on startup before config exists | Always call saveDefaultConfig() before any getConfig() read |
| Item theft from inventory GUI | Cancel InventoryClickEvent AND InventoryDragEvent |
| GUI identified by title string breaks on rename | Use InventoryHolder pattern — check instanceof |
PlayerMoveEvent causing lag |
Guard: return early if block XYZ hasn't changed |
@Overwrite mixin conflicts |
Use @Inject(cancellable = true) and call ci.cancel() instead |
| Client mixin crashes dedicated server | Move class to "client" array in mixin JSON, not "mixins" |
Minecraft.getInstance() on server |
Gate with @Environment(EnvType.CLIENT) or FMLClientSetupEvent |
| Forge: wrong event bus | Mod lifecycle → modBus, game events → MinecraftForge.EVENT_BUS |
RegistryObject.get() too early |
Only call .get() after FMLCommonSetupEvent has fired |
| Duplicate holograms on restart | Remove all spawned entities in onDisable() |
| Forge packet crashes server | Always call ctx.setPacketHandled(true) in every packet handler |
| Soft dep NPE | Check Bukkit.getPluginManager().isPluginEnabled("Vault") before any call |
| Async event triggers exception | Schedule sync callback with Bukkit.getScheduler().runTask() |
TabCompleter returns null |
Return empty new ArrayList<>(), never null |
onCommand returns false |
Returns true unless you deliberately want usage string shown |
| Scoreboard flickering | Don't recreate objective every tick — update line values only |
| Missing language key in Forge/Fabric | Add block.mymod.name, item.mymod.name entries to en_us.json |