# Paper Plugin Core

> Minecraft Paper plugin development — modern practices only. Adventure API + MiniMessage (never legacy chat), Display entities (never ArmorStands for visuals), up-to-date Paper API, Brigadier/cloud commands with LuckPerms permission nodes, PDC, async patterns, Folia, MythicMobs API, ModelEngine API, Vault, PlaceholderAPI, WorldGuard, player abuse prevention, and publishing. Use when building, reviewing, or debugging any Paper plugin.

- Skill: `miscodings/paper-plugin-core` (Agent Skill)
- Install (CLI): `npx skillmds@latest add miscodings/paper-plugin-core`
- Raw SKILL.md: https://api.skillmd.com/api/skills/miscodings/paper-plugin-core/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: DevOps & Infra
- Author: Miscodings (https://skillmd.com/u/miscodings)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/miscodings/paper-plugin-core

---


# 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)

1. **Never use legacy chat** — no `ChatColor`, no `§` codes, no `setDisplayName(String)`. Always Adventure API.
2. **Never use ArmorStands for visual purposes** — use Display entities (`TextDisplay`, `BlockDisplay`, `ItemDisplay`) instead.
3. **Never call Bukkit API from async threads** — schedule back to main thread first.
4. **Never use `Player.getName()` as a storage key** — names change. Always use `Player.getUniqueId()`.
5. **Never block the main thread** — no `Thread.sleep()`, no synchronous I/O, no large loops on tick.
6. **Never store `Player` references in collections** — players disconnect. Store `UUID`.
7. **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`
```kotlin
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`
```yaml
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)
```kotlin
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
```kotlin
// 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)
```yaml
# config.yml
messages:
  no-permission: "<red>You don't have permission to do that."
  welcome: "<green>Welcome, <player>!"
```
```kotlin
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)
```kotlin
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)
```kotlin
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)
```kotlin
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)
```kotlin
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
```kotlin
// 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

```kotlin
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 decisions
- `NORMAL` — default for most logic
- `HIGH`/`HIGHEST` — override other plugins
- `MONITOR` — **read-only, never cancel/modify**

Always set `ignoreCancelled = true` unless you specifically need to react to already-cancelled events.

### Custom events
```kotlin
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)
```kotlin
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)
```yaml
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

```kotlin
// 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)

```kotlin
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
```kotlin
// 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
```kotlin
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
```kotlin
// 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
```kotlin
@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
```kotlin
server.scheduler.runTaskLater(plugin, Runnable {
    // Player may have logged out during the delay:
    if (!player.isOnline) return@Runnable
    val online = server.getPlayer(player.uniqueId) ?: return@Runnable
    online.sendMessage(plugin.mm.deserialize("<green>Your item is ready!"))
}, 100L)
```

### Prevent duplicate entries
```kotlin
// 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
```kotlin
// 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
```kotlin
// 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

```kotlin
// 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)
```kotlin
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

```kotlin
// 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
```kotlin
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)
```kotlin
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)
```kotlin
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)
```kotlin
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

```kotlin
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

```kotlin
@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`)
```yaml
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
```kotlin
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)
