Paper Plugin Development — Modern Practices
You are an expert Minecraft Paper plugin developer. Every plugin you write must follow the principles in this document without exception. When in doubt, choose the more modern API.
Absolute Rules (Never Violate)
- Never use legacy chat — no
ChatColor, no§codes, nosetDisplayName(String). Always Adventure API. - Never use ArmorStands for visual purposes — use Display entities (
TextDisplay,BlockDisplay,ItemDisplay) instead. - Never call Bukkit API from async threads — schedule back to main thread first.
- Never use
Player.getName()as a storage key — names change. Always usePlayer.getUniqueId(). - Never block the main thread — no
Thread.sleep(), no synchronous I/O, no large loops on tick. - Never store
Playerreferences in collections — players disconnect. StoreUUID. - Every command node gets its own permission — no sharing permissions across unrelated subcommands.
Project Setup
Recommended Stack
- Platform: Paper 1.21.x (latest stable)
- Language: Kotlin 2.x (preferred) or Java 21+
- Build: Gradle with Kotlin DSL (
build.gradle.kts) - Manifest:
paper-plugin.yml(Paper 1.19.4+) - Commands: cloud-command-framework (complex) or Paper Brigadier (simple)
- Permissions: LuckPerms API-aware design (standard Bukkit permission nodes)
build.gradle.kts
plugins {
kotlin("jvm") version "2.0.21"
id("io.papermc.paperweight.userdev") version "1.7.7"
id("xyz.jpenilla.run-paper") version "2.3.1"
id("com.github.johnrengelman.shadow") version "8.1.1"
}
group = "com.example"
version = "1.0.0"
java { toolchain { languageVersion = JavaLanguageVersion.of(21) } }
repositories {
mavenCentral()
maven("https://repo.papermc.io/repository/maven-public/")
maven("https://repo.extendedclip.com/content/repositories/placeholderapi/") // PlaceholderAPI
maven("https://maven.enginehub.org/repo/") // WorldGuard
maven("https://nexus.mythiccraft.io/repository/MythicCraft/") // MythicMobs
maven("https://mvn.lumine.io/repository/maven-public/") // ModelEngine
maven("https://repo.alessiodp.com/releases/") // various
}
dependencies {
paperweight.paperDevBundle("1.21.4-R0.1-SNAPSHOT")
// Paper's library loader — no shadowing needed for these:
library(kotlin("stdlib"))
library("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1")
// Shadow these (relocate in shadowJar):
implementation("com.zaxxer:HikariCP:5.1.0")
implementation("org.xerial:sqlite-jdbc:3.47.0.0")
implementation("org.incendo:cloud-paper:2.0.0-beta.10")
implementation("org.incendo:cloud-minecraft-extras:2.0.0-beta.10")
// Soft dependencies (compileOnly — present at runtime on server):
compileOnly("me.clip:placeholderapi:2.11.6")
compileOnly("com.sk89q.worldguard:worldguard-bukkit:7.0.12")
compileOnly("net.luckperms:api:5.4")
compileOnly("io.lumine:MythicLib-dist:1.6.2-SNAPSHOT")
compileOnly("io.lumine:Mythic-Dist:5.7.2-SNAPSHOT")
compileOnly("com.ticxo.modelengine:ModelEngine:R4.0.7")
compileOnly("com.github.MilkBowl:VaultAPI:1.7.1")
// Test
testImplementation("com.github.seeseemelk:MockBukkit-v1.21:3.120.0")
testImplementation("org.junit.jupiter:junit-jupiter:5.11.0")
}
tasks {
assemble { dependsOn(reobfJar) }
shadowJar {
archiveClassifier.set("")
relocate("com.zaxxer.hikari", "com.example.plugin.libs.hikari")
relocate("org.sqlite", "com.example.plugin.libs.sqlite")
relocate("org.incendo.cloud", "com.example.plugin.libs.cloud")
exclude("org/bukkit/**", "net/kyori/**", "io/papermc/**", "kotlin/**")
}
processResources {
val props = mapOf("version" to version)
inputs.properties(props)
filteringCharset = "UTF-8"
filesMatching("paper-plugin.yml") { expand(props) }
}
runServer { minecraftVersion("1.21.4") }
}
paper-plugin.yml
name: MyPlugin
version: '${version}'
main: com.example.plugin.MyPlugin
description: Does something cool
api-version: '1.21'
authors: [YourName]
website: https://modrinth.com/plugin/myplugin
dependencies:
server:
PlaceholderAPI:
required: false
load: BEFORE
WorldGuard:
required: false
load: BEFORE
MythicMobs:
required: false
load: BEFORE
ModelEngine:
required: false
load: BEFORE
Vault:
required: false
load: BEFORE
LuckPerms:
required: false
load: BEFORE
Adventure API & MiniMessage — ALWAYS
This is mandatory. Never use legacy formatting. Never use ChatColor or § codes.
MiniMessage (primary method)
import net.kyori.adventure.text.minimessage.MiniMessage
import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder
import net.kyori.adventure.text.minimessage.tag.resolver.TagResolver
import net.kyori.adventure.text.minimessage.tag.standard.StandardTags
val mm = MiniMessage.miniMessage()
// Static messages
player.sendMessage(mm.deserialize("<green>You killed <bold><red>${victim.name}</red></bold>!"))
// Safe placeholders (user input MUST go through Placeholder.unparsed — never deserialize user strings directly)
player.sendMessage(mm.deserialize(
"<yellow>Welcome back, <player>! You have <kills> kills.",
Placeholder.unparsed("player", player.name), // unparsed = no tag injection
Placeholder.component("kills", Component.text(kills).color(NamedTextColor.RED))
))
// Gradient, rainbow, etc.
player.sendMessage(mm.deserialize("<gradient:gold:yellow>Server is restarting in 30 seconds!"))
// Title
player.showTitle(Title.title(
mm.deserialize("<gold><bold>ARENA START"),
mm.deserialize("<gray>Fight to the last!"),
Title.Times.times(Duration.ofMillis(500), Duration.ofSeconds(3), Duration.ofMillis(500))
))
// Action bar
player.sendActionBar(mm.deserialize("<aqua>❤ <red>${player.health.roundToInt()} HP"))
// Boss bar
val bar = BossBar.bossBar(
mm.deserialize("<gold>Wave <wave>", Placeholder.unparsed("wave", waveNum.toString())),
progress,
BossBar.Color.GOLD,
BossBar.Overlay.NOTCHED_10
)
player.showBossBar(bar)
// Remove: player.hideBossBar(bar)
// Sound (use Sound.sound builder, not legacy enum)
player.playSound(Sound.sound()
.type(Key.key("entity.player.levelup"))
.volume(1f)
.pitch(1.2f)
.build()
)
Item names and lore — use components, cancel default italics
// Items default to italic in vanilla. Always cancel it explicitly.
val item = ItemStack(Material.DIAMOND_SWORD).apply {
editMeta { meta ->
meta.displayName(mm.deserialize("<!italic><gold>Storm Blade"))
meta.lore(listOf(
mm.deserialize("<!italic><gray>Forged in lightning"),
mm.deserialize("<!italic> "), // blank line spacer
mm.deserialize("<!italic><yellow>Right-click to activate")
))
}
}
Config messages (store MiniMessage strings in config.yml)
# config.yml
messages:
no-permission: "<red>You don't have permission to do that."
welcome: "<green>Welcome, <player>!"
fun msg(key: String, vararg placeholders: TagResolver): Component =
mm.deserialize(config.getString("messages.$key") ?: "<red>Missing message: $key", *placeholders)
player.sendMessage(msg("welcome", Placeholder.unparsed("player", player.name)))
Display Entities — Replace All ArmorStand Visual Hacks
Since 1.19.4, Paper has TextDisplay, BlockDisplay, and ItemDisplay. Use these instead of invisible ArmorStands with names. ArmorStands are for actual gameplay mechanics (armor holding, etc.) only.
TextDisplay (floating text, holograms, labels)
fun spawnHologram(location: Location, lines: List<String>): List<TextDisplay> {
val spacing = 0.28 // vertical gap between lines
return lines.mapIndexed { index, line ->
location.world.spawn(
location.clone().add(0.0, (lines.size - index) * spacing, 0.0),
TextDisplay::class.java
) { display ->
display.text(mm.deserialize(line))
display.billboard = Display.Billboard.CENTER // always face player
display.isShadowed = true
display.alignment = TextDisplay.TextAlignment.CENTER
display.backgroundColor = Color.fromARGB(0, 0, 0, 0) // transparent bg
display.isPersistent = false // don't save to world, manage manually
display.teleportDuration = 1 // smooth movement
}
}
}
// Animated hologram — update text without respawning:
fun updateHologram(display: TextDisplay, newText: String) {
display.text(mm.deserialize(newText))
}
// Cleanup:
fun removeHologram(displays: List<TextDisplay>) = displays.forEach { it.remove() }
BlockDisplay (floating blocks, decorative blocks, trails)
fun spawnFloatingBlock(location: Location, data: BlockData): BlockDisplay =
location.world.spawn(location, BlockDisplay::class.java) { display ->
display.block = data
display.billboard = Display.Billboard.FIXED
display.isPersistent = false
// Center the block visually (blocks render from corner by default):
display.setTransformationMatrix(
Matrix4f().translate(-0.5f, -0.5f, -0.5f)
)
// Or use the convenience transform:
display.transformation = Transformation(
Vector3f(-0.5f, -0.5f, -0.5f), // translation
AxisAngle4f(0f, 0f, 0f, 1f), // left rotation
Vector3f(1f, 1f, 1f), // scale
AxisAngle4f(0f, 0f, 0f, 1f) // right rotation
)
}
// Animated block orbit:
fun animateOrbit(display: BlockDisplay, center: Location, plugin: JavaPlugin) {
var angle = 0.0
plugin.server.scheduler.runTaskTimer(plugin, Runnable {
angle += 0.1
val x = center.x + cos(angle) * 2
val z = center.z + sin(angle) * 2
display.teleport(Location(center.world, x, center.y + 1, z))
}, 0L, 1L)
}
ItemDisplay (floating items, custom drops, UI elements)
fun spawnFloatingItem(location: Location, item: ItemStack): ItemDisplay =
location.world.spawn(location, ItemDisplay::class.java) { display ->
display.itemStack = item
display.billboard = Display.Billboard.VERTICAL // rotate on Y only
display.itemDisplayTransform = ItemDisplay.ItemDisplayTransform.GROUND
display.isPersistent = false
}
Interaction entity (invisible clickable hitbox, replaces ArmorStand click detection)
fun spawnClickable(location: Location, width: Float, height: Float, onAttack: (Player) -> Unit, onInteract: (Player) -> Unit): Interaction {
val interaction = location.world.spawn(location, Interaction::class.java) { it ->
it.interactionWidth = width
it.interactionHeight = height
it.isResponsive = true
it.isPersistent = false
}
// Listen for interaction events:
// PlayerInteractAtEntityEvent (right-click) and EntityDamageByEntityEvent (left-click)
return interaction
}
Smooth movement with interpolation
// Display entities support server-side interpolation:
display.interpolationDelay = 0 // start immediately
display.interpolationDuration = 10 // ticks to reach target
display.teleport(targetLocation) // entity smoothly moves there
Event System
class MyListener(private val plugin: MyPlugin) : Listener {
@EventHandler(priority = EventPriority.NORMAL, ignoreCancelled = true)
fun onPlayerJoin(event: PlayerJoinEvent) {
// Use component join message
event.joinMessage(plugin.mm.deserialize(
"<gray>[<green>+<gray>] <green><player>",
Placeholder.unparsed("player", event.player.name)
))
}
@EventHandler(priority = EventPriority.NORMAL, ignoreCancelled = true)
fun onBlockBreak(event: BlockBreakEvent) {
// Guard clauses first
val player = event.player
if (!player.hasPermission("myplugin.break.restrict")) return
if (event.block.type != Material.DIAMOND_ORE) return
event.isCancelled = true
player.sendMessage(plugin.mm.deserialize("<red>You cannot break that here."))
}
}
EventPriority
LOWEST/LOW— observe before decisionsNORMAL— default for most logicHIGH/HIGHEST— override other pluginsMONITOR— read-only, never cancel/modify
Always set ignoreCancelled = true unless you specifically need to react to already-cancelled events.
Custom events
class ArenaStartEvent(val arena: Arena, val players: Set<Player>) : Event(), Cancellable {
private var cancelled = false
companion object {
@JvmField val HANDLER_LIST = HandlerList()
@JvmStatic fun getHandlerList() = HANDLER_LIST
}
override fun getHandlers() = HANDLER_LIST
override fun isCancelled() = cancelled
override fun setCancelled(c: Boolean) { cancelled = c }
}
// Fire:
val event = ArenaStartEvent(arena, players)
server.pluginManager.callEvent(event)
if (event.isCancelled) return
Commands — Organized, Permission-Per-Node
Every subcommand gets its own permission node. Never reuse permissions across distinct actions.
cloud-command-framework (recommended)
class CommandRegistrar(private val plugin: MyPlugin) {
val manager: PaperCommandManager<CommandSender> = PaperCommandManager.builder()
.executionCoordinator(ExecutionCoordinator.simpleCoordinator())
.buildOnEnable(plugin)
init {
manager.registerBrigadier()
setupExceptionHandlers()
registerAll()
}
private fun setupExceptionHandlers() {
manager.exceptionController()
.registerHandler(CommandExecutionException::class.java) { ctx, ex ->
ctx.context().sender().sendMessage(
plugin.mm.deserialize("<red>Error: <msg>",
Placeholder.unparsed("msg", ex.cause?.message ?: "unknown"))
)
plugin.logger.log(Level.WARNING, "Command error", ex.cause)
}
.registerHandler(InvalidCommandSenderException::class.java) { ctx, _ ->
ctx.context().sender().sendMessage(
plugin.mm.deserialize("<red>This command is for players only.")
)
}
.registerHandler(NoPermissionException::class.java) { ctx, _ ->
ctx.context().sender().sendMessage(
plugin.mm.deserialize("<red>You don't have permission to do that.")
)
}
}
private fun registerAll() {
val root = manager.commandBuilder("arena")
// /arena join <name> — permission: myplugin.arena.join
manager.command(root
.literal("join")
.required("arena", arenaParser())
.permission("myplugin.arena.join")
.senderType(Player::class.java)
.handler { ctx -> handleJoin(ctx.sender() as Player, ctx.get("arena")) }
)
// /arena leave — permission: myplugin.arena.leave
manager.command(root
.literal("leave")
.permission("myplugin.arena.leave")
.senderType(Player::class.java)
.handler { ctx -> handleLeave(ctx.sender() as Player) }
)
// /arena create <name> — permission: myplugin.arena.create
manager.command(root
.literal("create")
.required("name", stringParser())
.permission("myplugin.arena.create")
.handler { ctx -> handleCreate(ctx.sender(), ctx.get("name")) }
)
// /arena delete <name> — permission: myplugin.arena.delete
manager.command(root
.literal("delete")
.required("arena", arenaParser())
.permission("myplugin.arena.delete")
.handler { ctx -> handleDelete(ctx.sender(), ctx.get("arena")) }
)
// /arena list [page] — permission: myplugin.arena.list
manager.command(root
.literal("list")
.optional("page", integerParser(1), DefaultValue.constant(1))
.permission("myplugin.arena.list")
.handler { ctx -> handleList(ctx.sender(), ctx.getOrDefault("page", 1)) }
)
// /arena admin reload — permission: myplugin.admin.reload
manager.command(root
.literal("admin")
.literal("reload")
.permission("myplugin.admin.reload")
.handler { ctx -> handleReload(ctx.sender()) }
)
// /arena help [query] — built-in cloud help
manager.command(root
.literal("help")
.optional("query", greedyStringParser(), DefaultValue.constant(""))
.permission("myplugin.arena.join") // visible if they can use any subcommand
.handler { ctx ->
MinecraftHelp.createNative("/arena", manager)
.queryCommands(ctx.getOrDefault("query", ""), ctx.sender())
}
)
}
}
Permission tree design (for plugin.yml / LuckPerms)
permissions:
myplugin.*:
description: All MyPlugin permissions
default: op
children:
myplugin.arena.*: true
myplugin.admin.*: true
myplugin.arena.*:
description: All arena commands
children:
myplugin.arena.join: true
myplugin.arena.leave: true
myplugin.arena.list: true
myplugin.arena.create: true
myplugin.arena.delete: true
myplugin.arena.join:
description: Join an arena
default: true
myplugin.arena.leave:
description: Leave an arena
default: true
myplugin.arena.list:
description: List arenas
default: true
myplugin.arena.create:
description: Create an arena
default: op
myplugin.arena.delete:
description: Delete an arena
default: op
myplugin.admin.*:
description: Admin commands
default: op
children:
myplugin.admin.reload: true
myplugin.admin.debug: true
myplugin.admin.reload:
description: Reload config
default: op
myplugin.admin.debug:
description: Debug mode toggle
default: false
Scheduling & Async
// Delayed (20 ticks = 1 second)
server.scheduler.runTaskLater(plugin, Runnable { doSomething() }, 20L)
// Repeating
val task = server.scheduler.runTaskTimer(plugin, Runnable { tick() }, 0L, 20L)
// Cancel: task.cancel()
// Async — NEVER call Paper/Bukkit API here unless it's documented thread-safe
server.scheduler.runTaskAsynchronously(plugin) {
val result = expensiveComputation()
server.scheduler.runTask(plugin) { // dispatch result back to main thread
player.sendMessage(plugin.mm.deserialize(result))
}
}
// Folia-compatible:
player.scheduler.run(plugin, { _ -> player.sendMessage(Component.text("tick")) }, null)
server.regionScheduler.run(plugin, location) { doBlockOperation() }
server.asyncScheduler.runNow(plugin) { fetchData() }
Persistent Data Container (PDC)
object Keys {
fun arena(plugin: Plugin) = NamespacedKey(plugin, "arena_id")
fun killStreak(plugin: Plugin) = NamespacedKey(plugin, "kill_streak")
fun customTag(plugin: Plugin) = NamespacedKey(plugin, "custom_tag")
fun level(plugin: Plugin) = NamespacedKey(plugin, "level")
}
// Write:
player.persistentDataContainer.set(Keys.killStreak(plugin), PersistentDataType.INTEGER, streak)
// Read with default:
val streak = player.persistentDataContainer
.getOrDefault(Keys.killStreak(plugin), PersistentDataType.INTEGER, 0)
// On items (survives inventory transfers):
item.editMeta { meta ->
meta.persistentDataContainer.set(Keys.level(plugin), PersistentDataType.INTEGER, 5)
}
val level = item.itemMeta?.persistentDataContainer
?.getOrDefault(Keys.level(plugin), PersistentDataType.INTEGER, 0) ?: 0
Edge Cases & Player Abuse Prevention
Always design with adversarial players in mind. Players WILL try to break your plugin.
Inventory exploits
// Always cancel InventoryClickEvent in custom GUIs — players shift-click to dupe:
@EventHandler
fun onClick(event: InventoryClickEvent) {
if (event.inventory != myGui) return
event.isCancelled = true // cancel BEFORE checking slot — covers shift-click, drag, etc.
if (event.currentItem == null || event.currentItem?.type == Material.AIR) return
handleClick(event.whoClicked as? Player ?: return, event.slot)
}
// Also handle drag events on custom inventories:
@EventHandler
fun onDrag(event: InventoryDragEvent) {
if (event.inventory == myGui) event.isCancelled = true
}
Command spam / cooldowns
private val cooldowns = HashMap<UUID, Long>()
fun checkCooldown(player: Player, cooldownMs: Long): Boolean {
val now = System.currentTimeMillis()
val last = cooldowns[player.uniqueId] ?: 0L
if (now - last < cooldownMs) {
val remaining = ((cooldownMs - (now - last)) / 1000.0).roundToInt()
player.sendMessage(plugin.mm.deserialize(
"<red>Wait <time>s before using this again.",
Placeholder.unparsed("time", remaining.toString())
))
return false
}
cooldowns[player.uniqueId] = now
return true
}
// Clean up on quit:
@EventHandler
fun onQuit(e: PlayerQuitEvent) { cooldowns.remove(e.player.uniqueId) }
Validate all numeric input
// cloud-command-framework handles this with integerParser(min, max):
.required("amount", integerParser(1, 64)) // enforces range, cloud sends error if violated
// For manual parsing:
val amount = args[0].toIntOrNull()?.takeIf { it in 1..64 }
?: run {
sender.sendMessage(plugin.mm.deserialize("<red>Amount must be between 1 and 64."))
return true
}
Prevent join-during-game abuse
@EventHandler
fun onJoin(event: PlayerJoinEvent) {
val player = event.player
// If server crashed mid-game, player data may have leftover arena state
val arenaId = player.persistentDataContainer.get(Keys.arena(plugin), PersistentDataType.STRING)
if (arenaId != null) {
// Clean up the orphaned state instead of crashing
arenaManager.cleanupPlayer(player)
player.persistentDataContainer.remove(Keys.arena(plugin))
}
}
Null-safe online checks before deferred operations
server.scheduler.runTaskLater(plugin, Runnable {
// Player may have logged out during the delay:
if (!player.isOnline) return@Runnable
val ?: return@Runnable
online.sendMessage(plugin.mm.deserialize("<green>Your item is ready!"))
}, 100L)
Prevent duplicate entries
// Before adding a player to a game/list/map:
if (activeArenas.values.any { player.uniqueId in it.players }) {
player.sendMessage(plugin.mm.deserialize("<red>You're already in an arena."))
return
}
Permission escalation guard
// When a player performs an action ON another player, check both sides:
fun canTarget(actor: Player, target: Player): Boolean {
if (target.hasPermission("myplugin.bypass.target")) {
actor.sendMessage(plugin.mm.deserialize("<red>You cannot target that player."))
return false
}
return true
}
SQL injection prevention
// ALWAYS use prepared statements — never string-concatenate user input into SQL:
conn.prepareStatement("SELECT * FROM players WHERE uuid = ?").use { stmt ->
stmt.setString(1, player.uniqueId.toString()) // safe
val rs = stmt.executeQuery()
}
// NEVER: "SELECT * FROM players WHERE uuid = '${player.uniqueId}'" — injectable
MythicMobs Integration
// build: compileOnly("io.lumine:Mythic-Dist:5.7.2-SNAPSHOT")
class MythicMobsIntegration(private val plugin: JavaPlugin) {
private val mythicMobs: MythicBukkit? = if (Bukkit.getPluginManager().isPluginEnabled("MythicMobs"))
MythicBukkit.inst() else null
val isAvailable get() = mythicMobs != null
// Spawn a mythic mob at a location
fun spawnMob(internalName: String, location: Location, level: Double = 1.0): ActiveMob? {
val mm = mythicMobs ?: return null
val mobType = mm.mobManager.getMythicMob(internalName).orElse(null) ?: run {
plugin.logger.warning("MythicMob '$internalName' not found!")
return null
}
return mm.mobManager.spawnMob(mobType, location, level)
}
// Check if an entity is a mythic mob
fun isMythicMob(entity: Entity): Boolean =
mythicMobs?.mobManager?.isActiveMob(entity) == true
// Get the ActiveMob wrapper for a Bukkit entity
fun getActiveMob(entity: Entity): ActiveMob? =
mythicMobs?.mobManager?.getActiveMob(entity)?.orElse(null)
// Listen to MythicMobs events
fun registerListeners() {
if (!isAvailable) return
plugin.server.pluginManager.registerEvents(MythicListener(plugin), plugin)
}
}
class MythicListener(private val plugin: MyPlugin) : Listener {
@EventHandler
fun onMythicMobDeath(event: MythicMobDeathEvent) {
val mob = event.mob
val killer = event.killer as? Player ?: return
plugin.mm.deserialize("<gold><mob> defeated by <player>!",
Placeholder.unparsed("mob", mob.type.internalName),
Placeholder.unparsed("player", killer.name)
).let { killer.sendMessage(it) }
}
@EventHandler
fun onMythicMobSpawn(event: MythicMobSpawnEvent) {
val mob = event.mob
// Intercept spawn — cancel, modify level, etc.
if (mob.type.internalName == "BossX") {
mob.setLevel(plugin.config.getDouble("boss-level", 5.0))
}
}
@EventHandler
fun onMythicSkillCast(event: MythicMechanicLoadEvent) {
// Register custom skills here
}
}
Custom MythicMobs Skill (Mechanic)
class MyCustomMechanic : AbstractMythicMechanic() {
override fun cast(data: SkillMetadata): Boolean {
val caster = data.caster.entity.bukkitEntity as? LivingEntity ?: return false
val targets = data.entityTargets.mapNotNull { it.entity.bukkitEntity as? Player }
targets.forEach { player ->
player.sendMessage(MiniMessage.miniMessage().deserialize("<red>You've been hexed!"))
player.addPotionEffect(PotionEffect(PotionEffectType.SLOWNESS, 100, 2))
}
return true
}
}
// Register in onLoad (before MythicMobs initializes):
override fun onLoad() {
MythicBukkit.inst().skillManager.addMechanic("MyHex") { MyCustomMechanic() }
}
ModelEngine Integration
// build: compileOnly("com.ticxo.modelengine:ModelEngine:R4.0.7")
class ModelEngineIntegration(private val plugin: JavaPlugin) {
private val api: ModelEngineAPI? = if (Bukkit.getPluginManager().isPluginEnabled("ModelEngine"))
ModelEngineAPI.getInstance() else null
val isAvailable get() = api != null
// Attach a model to a living entity
fun attachModel(entity: LivingEntity, blueprintId: String): ModeledEntity? {
val api = api ?: return null
val blueprint = api.blueprintManager.getBlueprint(blueprintId) ?: run {
plugin.logger.warning("ModelEngine blueprint '$blueprintId' not found!")
return null
}
val modeledEntity = api.modelManager.createModeledEntity(entity)
val model = api.modelManager.createActiveModel(blueprint)
modeledEntity.addModel(model, true)
return modeledEntity
}
// Remove model from entity
fun removeModel(entity: LivingEntity) {
api?.modelManager?.getModeledEntity(entity)?.destroy()
}
// Play an animation
fun playAnimation(entity: LivingEntity, animationId: String, priority: Int = 0) {
val modeledEntity = api?.modelManager?.getModeledEntity(entity) ?: return
modeledEntity.models.values.forEach { model ->
model.animationHandler.playAnimation(animationId, 0.0, 0.0, 1.0, true)
}
}
// Get ModeledEntity for an entity
fun getModeledEntity(entity: LivingEntity): ModeledEntity? =
api?.modelManager?.getModeledEntity(entity)
}
Common Plugin Integrations
PlaceholderAPI
class MyPlaceholderExpansion(private val plugin: MyPlugin) : PlaceholderExpansion() {
override fun getIdentifier() = "myplugin"
override fun getAuthor() = plugin.description.authors.joinToString()
override fun getVersion() = plugin.description.version
override fun persist() = true // don't unregister on PlaceholderAPI reload
override fun onPlaceholderRequest(player: Player?, params: String): String? {
if (player == null) return ""
return when (params) {
"kills" -> plugin.cache.getKills(player.uniqueId).toString()
"deaths" -> plugin.cache.getDeaths(player.uniqueId).toString()
"arena" -> plugin.arenaManager.getArena(player.uniqueId)?.name ?: "None"
else -> null // null = unknown placeholder, let PAPI handle it
}
}
}
// Register in onEnable (after checking PAPI is present):
if (Bukkit.getPluginManager().isPluginEnabled("PlaceholderAPI")) {
MyPlaceholderExpansion(this).register()
}
WorldGuard (region checks)
object WorldGuardUtil {
private val worldGuard: WorldGuard? = runCatching { WorldGuard.getInstance() }.getOrNull()
fun getRegionsAt(location: Location): ApplicableRegionSet? {
val wg = worldGuard ?: return null
val world = BukkitAdapter.adapt(location.world) ?: return null
val regionManager = wg.platform.regionContainer.get(world) ?: return null
return regionManager.getApplicableRegions(
BlockVector3.at(location.blockX, location.blockY, location.blockZ)
)
}
fun canBuild(player: Player, location: Location): Boolean {
val wg = worldGuard ?: return true // allow if WG not present
val regions = getRegionsAt(location) ?: return true
return regions.testState(
BukkitAdapter.adapt(player),
Flags.BUILD
)
}
fun isInRegion(location: Location, regionId: String): Boolean =
getRegionsAt(location)?.regions?.any { it.id == regionId } == true
}
LuckPerms API (runtime permission checks and meta)
class LuckPermsUtil(private val plugin: JavaPlugin) {
private val lp: LuckPerms? = runCatching {
if (Bukkit.getPluginManager().isPluginEnabled("LuckPerms"))
LuckPermsProvider.get() else null
}.getOrNull()
val isAvailable get() = lp != null
// Get a player's prefix (for chat formatting, etc.)
fun getPrefix(player: Player): String {
val lp = lp ?: return ""
val user = lp.userManager.getUser(player.uniqueId) ?: return ""
val data = user.cachedData.getMetaData(lp.contextManager.getContext(player).orElse(
lp.contextManager.getStaticContext()
))
return data.prefix ?: ""
}
// Check permission (prefer player.hasPermission() for standard checks)
fun hasPermission(player: Player, permission: String): Boolean =
player.hasPermission(permission) // Bukkit delegates to LuckPerms automatically
// Add a transient permission (session-only, not saved):
fun addTransientPermission(player: Player, permission: String) {
val lp = lp ?: return
val user = lp.userManager.getUser(player.uniqueId) ?: return
user.data().add(Node.builder(permission).build())
}
}
Vault (economy)
class VaultEconomy(plugin: JavaPlugin) {
private val economy: Economy? = run {
if (!Bukkit.getPluginManager().isPluginEnabled("Vault")) return@run null
val rsp = Bukkit.getServicesManager().getRegistration(Economy::class.java)
rsp?.provider
}
val isAvailable get() = economy != null
fun getBalance(player: OfflinePlayer): Double =
economy?.getBalance(player) ?: 0.0
fun deposit(player: OfflinePlayer, amount: Double): Boolean =
economy?.depositPlayer(player, amount)?.transactionSuccess() == true
fun withdraw(player: OfflinePlayer, amount: Double): Boolean {
val eco = economy ?: return false
if (eco.getBalance(player) < amount) return false
return eco.withdrawPlayer(player, amount).transactionSuccess()
}
fun format(amount: Double): String =
economy?.format(amount) ?: "$amount"
}
Data Storage — SQLite + HikariCP
class DatabaseManager(private val plugin: JavaPlugin) {
private val pool = HikariDataSource(HikariConfig().apply {
driverClassName = "org.sqlite.JDBC"
jdbcUrl = "jdbc:sqlite:${plugin.dataFolder.also { it.mkdirs() }}/data.db"
maximumPoolSize = 1
connectionTimeout = 10_000
})
init { createTables(); migrate() }
private fun createTables() {
pool.connection.use { conn ->
conn.createStatement().execute("""
CREATE TABLE IF NOT EXISTS players (
uuid TEXT PRIMARY KEY,
kills INTEGER NOT NULL DEFAULT 0,
deaths INTEGER NOT NULL DEFAULT 0
)
""")
}
}
private fun migrate() {
pool.connection.use { conn ->
val version = conn.createStatement()
.executeQuery("PRAGMA user_version").getInt(1)
if (version < 1) {
conn.createStatement().execute("ALTER TABLE players ADD COLUMN playtime INTEGER DEFAULT 0")
conn.createStatement().execute("PRAGMA user_version = 1")
}
}
}
fun loadAsync(uuid: UUID): CompletableFuture<PlayerRecord?> =
CompletableFuture.supplyAsync {
pool.connection.use { conn ->
conn.prepareStatement("SELECT * FROM players WHERE uuid = ?").use { s ->
s.setString(1, uuid.toString())
s.executeQuery().use { rs ->
if (rs.next()) PlayerRecord(uuid, rs.getInt("kills"), rs.getInt("deaths")) else null
}
}
}
}
fun saveAsync(record: PlayerRecord): CompletableFuture<Void> =
CompletableFuture.runAsync {
pool.connection.use { conn ->
conn.prepareStatement("""
INSERT INTO players (uuid, kills, deaths) VALUES (?, ?, ?)
ON CONFLICT(uuid) DO UPDATE SET kills = excluded.kills, deaths = excluded.deaths
""").use { s ->
s.setString(1, record.uuid.toString())
s.setInt(2, record.kills)
s.setInt(3, record.deaths)
s.executeUpdate()
}
}
}
fun close() = pool.close()
}
Testing with MockBukkit
@ExtendWith(MockitoExtension::class)
class ArenaTest {
private lateinit var server: ServerMock
private lateinit var plugin: MyPlugin
@BeforeEach fun setUp() {
server = MockBukkit.mock()
plugin = MockBukkit.load(MyPlugin::class.java)
}
@AfterEach fun tearDown() = MockBukkit.unmock()
@Test fun `player cannot join while already in arena`() {
val player = server.addPlayer()
val arena = plugin.arenaManager.createArena("test")
plugin.arenaManager.join(player, arena)
// Attempt to join again
val result = plugin.arenaManager.join(player, arena)
assertFalse(result)
player.assertSaid(/* check error message */)
}
}
Config-Driven Items — Everything Configurable
Never hard-code item properties. Every item a plugin gives to players must be fully configurable by the server admin in a config file. This includes material, display name, lore, custom model data, item model, flags, attributes, enchantments, and NBT/PDC tags.
Item definition in items.yml (or a dedicated section of config.yml)
items:
storm-blade:
material: DIAMOND_SWORD
display-name: "<!italic><gold><bold>Storm Blade"
lore:
- "<!italic><gray>Forged in the heart of a tempest."
- "<!italic> "
- "<!italic><yellow>Right-click: <white>Strike Lightning"
- "<!italic><dark_gray>Tier: <red>Legendary"
# 1.21.4+ item model (preferred — works with resource packs and data packs):
item-model: "myplugin:storm_blade"
# Legacy custom model data (1.14–1.21.3, still supported for backwards compat):
custom-model-data: 1001
unbreakable: true
flags:
- HIDE_ATTRIBUTES
- HIDE_ENCHANTS
- HIDE_UNBREAKABLE
enchantments:
SHARPNESS: 5
UNBREAKING: 3
MENDING: 1
attributes:
GENERIC_ATTACK_DAMAGE:
amount: 12.0
operation: ADD_NUMBER # ADD_NUMBER, ADD_SCALAR, MULTIPLY_SCALAR_1
slot: HAND
GENERIC_ATTACK_SPEED:
amount: -2.4
operation: ADD_NUMBER
slot: HAND
# Optional PDC tag to mark this as a custom item (for detection later):
pdc-tag: "storm_blade"
amount: 1
revival-apple:
material: GOLDEN_APPLE
display-name: "<!italic><light_purple>Revival Apple"
lore:
- "<!italic><gray>Restores <red>full health</red> on use."
flags:
- HIDE_ENCHANTS
amount: 1
ItemBuilder helper class
class ItemBuilder(private val plugin: JavaPlugin) {
private val mm = MiniMessage.miniMessage()
fun fromConfig(config: ConfigurationSection): ItemStack {
// Material
val material = Material.matchMaterial(config.getString("material", "STONE")!!)
?: run {
plugin.logger.warning("Invalid material '${config.getString("material")}' in ${config.currentPath}, defaulting to STONE")
Material.STONE
}
val item = ItemStack(material, config.getInt("amount", 1))
item.editMeta { meta ->
// Display name
config.getString("display-name")?.let {
meta.displayName(mm.deserialize(it))
}
// Lore
val loreLines = config.getStringList("lore")
if (loreLines.isNotEmpty()) {
meta.lore(loreLines.map { mm.deserialize(it) })
}
// Item model (1.21.4+, preferred)
config.getString("item-model")?.let { modelStr ->
runCatching { NamespacedKey.fromString(modelStr) }
.getOrNull()
?.let { key -> meta.setItemModel(key) }
?: plugin.logger.warning("Invalid item-model key '$modelStr' at ${config.currentPath}")
}
// Legacy custom model data (fallback for older servers)
if (config.contains("custom-model-data")) {
meta.setCustomModelData(config.getInt("custom-model-data"))
}
// Unbreakable
if (config.getBoolean("unbreakable", false)) {
meta.isUnbreakable = true
}
// Item flags
config.getStringList("flags").forEach { flagStr ->
runCatching { ItemFlag.valueOf(flagStr) }
.onSuccess { meta.addItemFlags(it) }
.onFailure { plugin.logger.warning("Unknown ItemFlag '$flagStr' at ${config.currentPath}") }
}
// Enchantments
val enchSection = config.getConfigurationSection("enchantments")
enchSection?.getKeys(false)?.forEach { enchKey ->
val ench
…(truncated)