Build a Boss Fight Datapack with Kore
Table of Contents
- Build a Boss Fight Datapack with Kore
- Prerequisites
- What we are building
- Step 1: Forging the legendary weapon
- Step 2: The fight state machine
- Step 3: The boss bar
- Step 4: Arena VFX
- Step 5: Wiring the fight together
- Starting the fight
- The fight loop
- Winning
- Step 6: Assembling the datapack
- Going further
- Conclusion
The first Kore tutorials on this site stay small on purpose: a Hello World, a timer, some zone detection. They are great to learn the syntax, but they never show what Kore is actually built for: big systems that would be a nightmare in raw JSON.
So this time we go bigger. We are going to build a full boss fight, end to end, in pure Kotlin:
- A legendary weapon crafted with item components (name, lore, enchantments, attribute modifiers).
- A custom boss with boosted health and a name.
- An OOP boss bar that tracks the fight phases.
- Particle VFX for the arena intro and the victory burst.
- A state machine that ties the whole flow together (
lobbytofightingtovictory).
Everything below is real Kore code. By the end you will have a single main() that generates a datapack you can drop into a world and play.
Prerequisites
Before we begin, make sure you have:
- Minecraft Java Edition 1.21.11.
- A basic understanding of Minecraft commands.
- Kotlin and a Kotlin-compatible IDE (IntelliJ IDEA or PyCharm).
- Kore 2.0 or later. The OOP boss bar and state machine helpers we use here landed in the 2.x line, so anything older will not compile. If you have not set Kore up yet, check the Kore Introduction or just clone the Kore Template.
This project uses three Kore modules. The core kore module does most of the work, and we pull in oop for the boss bar and state machine, plus helpers for the particle shapes:
dependencies {
implementation("io.github.ayfri.kore:kore:VERSION")
implementation("io.github.ayfri.kore:oop:VERSION")
implementation("io.github.ayfri.kore:helpers:VERSION")
}
Kore versions are tagged per Minecraft version, so for 1.21.11 you want a 2.0.x-1.21.11 build (or newer). Grab the exact latest from Maven Central or the releases page.
What we are building
The flow is simple and readable, which is exactly the point. A player runs a command, the arena lights up, the boss spawns with a boss bar, and once it dies everyone gets a victory burst. We model that flow as three states and let Kore generate every function, tag, and JSON file behind it.
Let's build it piece by piece, then assemble everything in one main() at the end.
Step 1: Forging the legendary weapon
In vanilla, giving a player a fully customized item means writing a wall of nested JSON inside a /give command, with zero autocomplete and zero safety. One typo in a component name and the command silently does nothing.
With Kore, an item is just a typed builder. Here is the sword our hero uses against the boss:
import io.github.ayfri.kore.arguments.chatcomponents.textComponent
import io.github.ayfri.kore.arguments.chatcomponents.text
import io.github.ayfri.kore.arguments.colors.Color
import io.github.ayfri.kore.arguments.components.item.Rarities
import io.github.ayfri.kore.commands.AttributeModifierOperation
import io.github.ayfri.kore.generated.Attributes
import io.github.ayfri.kore.generated.Enchantments
import io.github.ayfri.kore.generated.Items
import io.github.ayfri.kore.arguments.types.literals.randomUUID
val dragonSlayer = Items.NETHERITE_SWORD {
customName(textComponent("Dragon Slayer", Color.GOLD) { bold = true })
lore(
textComponent("Forged to end the fight.", Color.GRAY) +
text(" Hits harder than it should.", Color.DARK_GRAY)
)
enchantments(mapOf(Enchantments.SHARPNESS to 5, Enchantments.FIRE_ASPECT to 2))
unbreakable()
rarity(Rarities.EPIC)
attributeModifiers {
modifier(
type = Attributes.ATTACK_DAMAGE,
amount = 6.0,
name = "Slayer Strike",
operation = AttributeModifierOperation.ADD_VALUE,
uuid = randomUUID(),
)
}
}
Every single line here is checked at compile time. Enchantments.SHARPNESS, Attributes.ATTACK_DAMAGE, the Rarities enum: they all come from generated registries, so you cannot misspell a key or feed a value that does not exist. Refactor the name once and your whole codebase follows.
For the full catalog of components (food, fireworks, custom data, profiles, and more), check the Components guide.
Step 2: The fight state machine
Instead of juggling scoreboard scores by hand, we use the OOP module's state machine. We declare the states once and Kore generates the objective and the initial state for us:
import io.github.ayfri.kore.gamestate.registerGameStates
val fight = registerGameStates {
state("lobby")
state("fighting")
state("victory")
}
From now on, switching phases is one readable call, fight.transitionTo("fighting"), and reacting to a phase is fight.whenState("...") { }. No magic numbers, no manual objective bookkeeping. See the Game State Machine docs for the integration helpers (timers, spawners, cooldowns).
Step 3: The boss bar
The OOP module wraps Minecraft boss bars into a config-plus-handle pattern. You register the bar once with its initial settings, and Kore generates a load function that creates it:
import io.github.ayfri.kore.bossbar.registerBossBar
import io.github.ayfri.kore.arguments.colors.BossBarColor
import io.github.ayfri.kore.commands.BossBarStyle
val bossBar = registerBossBar("dragon_bar") {
displayName = textComponent("The Ender Warden", Color.LIGHT_PURPLE)
color = BossBarColor.PURPLE
style = BossBarStyle.NOTCHED_10
max = 100
value = 100
}
Later, from any function, we just call bossBar.setValue(...), bossBar.show(), or bossBar.hide(). One place to configure, reused everywhere. Full method list in the Boss Bars docs.
Step 4: Arena VFX
A boss fight needs presence. The helpers VFX engine pre-computes geometric particle shapes at generation time and bakes them into a reusable function, so you describe the shape once instead of writing dozens of /particle lines:
import io.github.ayfri.kore.helpers.vfx.drawShape
import io.github.ayfri.kore.helpers.vfx.Shape
import io.github.ayfri.kore.generated.Particles
fun DataPack.arenaIntro() = drawShape("arena_intro") {
shape = Shape.SPIRAL
particle = Particles.SOUL_FIRE_FLAME
radius = 4.0
points = 60
height = 6.0
turns = 5
}
fun DataPack.victoryBurst() = drawShape("victory_burst") {
shape = Shape.SPHERE
particle = Particles.HAPPY_VILLAGER
radius = 3.0
points = 80
}
Each call returns a function reference we can run wherever we want. Check the VFX Particles docs for every shape and parameter.
Step 5: Wiring the fight together
Now the fun part. We have a weapon, a state machine, a boss bar, and effects. Let's connect them into actual gameplay functions.
Starting the fight
When the player starts the fight, we give them the weapon, play the intro spiral, spawn the boss, and flip into the fighting state:
import io.github.ayfri.kore.arguments.types.literals.allPlayers
import io.github.ayfri.kore.arguments.types.literals.self
import io.github.ayfri.kore.arguments.types.literals.vec3
import io.github.ayfri.kore.commands.give
import io.github.ayfri.kore.commands.summon
import io.github.ayfri.kore.functions.function
import io.github.ayfri.kore.generated.Entities
import io.github.ayfri.kore.utils.nbt
fun DataPack.startFight() = function("start_fight") {
give(allPlayers(), dragonSlayer)
function(arenaIntro()) // run the generated spiral effect
// Summon a beefed-up Warden tagged so we can find it later.
summon(Entities.WARDEN, vec3(), nbt {
this["CustomName"] = textComponent("The Ender Warden", Color.LIGHT_PURPLE)
this["Tags"] = listOf("boss")
// Java 1.21+ uses the lowercase "attributes" list with "id" + "base".
this["attributes"] = listOf(nbt {
this["id"] = "minecraft:max_health"
this["base"] = 100.0
})
this["Health"] = 100f
})
bossBar.apply {
setPlayers(allPlayers())
setValue(100)
show()
}
fight.transitionTo("fighting")
}
The summon builder takes the entity type, a position, and an NBT block. Because Kore exposes the NBT DSL directly, you build the boss data with the same typed nbt { } you use everywhere else, no string concatenation.
The fight loop
Each tick, while we are in the fighting state, we keep the boss bar in sync and check whether the boss is still alive. When the boss tag no longer matches any entity, the fight is won:
import io.github.ayfri.kore.arguments.types.literals.allEntities
import io.github.ayfri.kore.commands.execute.execute
import io.github.ayfri.kore.functions.tick
fun DataPack.fightLoop() = tick {
fight.whenState("fighting") {
// While at least one boss exists, keep the bar shown.
execute {
asTarget(allEntities { tag = "boss" })
run {
bossBar.show()
}
}
// No more boss tagged entity -> the fight is over.
execute {
unlessCondition {
entity(allEntities { tag = "boss" })
}
run {
function(winFight())
}
}
}
}
Winning
Victory hides the bar, fires the celebratory sphere of particles, congratulates the players, and resets the state so the fight can be replayed:
import io.github.ayfri.kore.commands.tellraw
fun DataPack.winFight() = function("win_fight") {
bossBar.hide()
function(victoryBurst())
tellraw(allPlayers(), textComponent("The Ender Warden has fallen!", Color.GREEN) { bold = true })
fight.transitionTo("victory")
}
Step 6: Assembling the datapack
All the pieces are independent functions hanging off DataPack, so the final main() reads like a table of contents. That readability is the whole reason to build datapacks this way:
import io.github.ayfri.kore.dataPack
import io.github.ayfri.kore.pack.pack
import kotlin.io.path.Path
fun main() {
val datapack = dataPack("boss_fight") {
pack {
description = textComponent("Boss Fight", Color.GOLD) +
text(" powered by Kore", Color.AQUA)
}
// Gameplay. The effect functions are generated lazily the first time
// startFight() and winFight() reference them, so we only wire these two.
startFight()
fightLoop()
path = Path("path/to/your/minecraft/saves/[Your World Name]/datapacks")
}
datapack.generateZip()
}
Run it, load the world, /reload, then trigger the start function with /function boss_fight:start_fight. The arena lights up, the Warden spawns under a purple boss bar, and the moment it dies everyone gets a victory burst. The whole thing is a few dozen lines of Kotlin instead of a folder full of fragile JSON.
Going further
This is a foundation you can push in a lot of directions:
- Phases: drop the boss bar color and value at health thresholds, and use
fight.whenState(...)to spawn adds or change attacks. - A start menu: instead of a raw
/functioncall, open a Dialog (1.21.6+) so players pick a difficulty before the fight begins. - Rewards: hand out a custom loot table or advancement on victory, again fully typed with components.
- Real health tracking: store the boss health into a scoreboard each tick and feed it straight into
bossBar.setValue(...)for a smooth bar instead of phase steps.
Every one of those additions is a small, local change because the logic lives in typed builders, not scattered strings. That is the part that matters when a pack stops being a toy and becomes a project you maintain for months.
Conclusion
We built a complete boss fight without touching a single .mcfunction or .json file by hand. A legendary weapon from item components, a custom boss, an OOP boss bar, baked particle VFX, and a state machine driving the flow, all in one Kotlin file that you can read top to bottom and actually understand a month later.
That is the real pitch for Kore: the small tutorials show you the syntax, but the payoff is everything you stop having to track in your head once your pack gets ambitious. For the full API, head to the Kore documentation and the Kore repository on GitHub.
Now go make something that hits back. Happy coding!