Build a Distributed Kotlin Item Engine
Build a Kotlin item engine that shares game state through Hazelcast.
Introduction
30 Second Summary
An upgraded weapon feels personal until a player moves to another server and the upgrade disappears. Shared progress depends on every server rebuilding the same item.
In this project, you will build a console-driven item engine in Kotlin for shared multiplayer items. The finished demonstration rebuilds one upgraded Minestom item from Hazelcast state on a second simulated server.
What You'll Build
Run the finished app to watch an upgraded Ember Blade reappear on a second simulated server with its identity, final statistics, and Ember Burst behavior intact.
By the end of this project, you'll have:
- A registered Ember Blade with a unique instance identity and a 15-point damage upgrade.
- A rendered item whose lore shows its calculated damage and strength while a hidden UUID preserves its identity.
- A distributed demonstration where local lookup reports the item as missing before a second member retrieves it and activates its locally registered ability.
- Secret Mission: Protect concurrent upgrades with a per-key IMap lock so both server updates survive.
Are there any prerequisites?
You should be comfortable reading Kotlin and working with Minestom item code. Everything runs locally on Linux with no paid service.
Before We Start
A distributed Kotlin item engine needs a clear boundary between the parts that define gameplay and the state that moves between servers. This project uses a small architecture that focuses on that boundary.
This preflight confirms that your JDK and IntelliJ IDEA versions meet the project requirements. It also establishes which item data remains local before you create any files.
In this step, get ready to:
- Confirm JDK 25 in your Linux terminal.
- Confirm IntelliJ IDEA 2025.2 or newer.
- Classify local behavior and shared item state.
Confirm the required tool versions
Minestom requires Java 25 or newer. Its current setup requirements also specify IntelliJ IDEA 2025.2 or newer.
- Open your Linux terminal from the application launcher.
- Check the active Java version by running this command:
java --version
What does this check?
This command reports the Java runtime selected by your terminal. The item engine needs major version 25 for its Minestom dependency.
You should see Java major version 25 in the output.
- Start IntelliJ IDEA from your Linux application launcher.
- Open About from IntelliJ IDEA's application menu.
- Confirm the displayed version is 2025.2 or newer.
Seeing a different version?
If the terminal reports another Java major version, configure your terminal to use the installed JDK 25. Run the version check again after changing the active runtime.
If IntelliJ IDEA shows an older version, update it to 2025.2 or newer before continuing.
Help me verify my Java and IntelliJ IDEA versions.
Your development tools now match the versions this project expects. That removes toolchain uncertainty before the item model starts growing.
Separate local behavior from shared state
Hazelcast gives the simulated server members a shared in-memory map. The map carries serialized instance data that another member can reconstruct.
- Keep ItemDefinition records on each server member.
- Keep ItemAbility lambdas in each server member's local registry.
- Share only the serialized ItemInstance record through Hazelcast's IMap.
- Use the shared definition ID to resolve the matching local definition.
Why does this boundary matter?
- ItemDefinition holds static item facts.
- ItemAbility holds executable behavior.
- ItemInstance holds identity plus upgrade state.
- Sharing only the encoded instance lets another server rebuild the item with its own local registries.
Before you confirm the boundary, predict which home each part needs. Does it belong to the server member or the shared map?
Your sentence should place item definitions and executable abilities on each server. It should place serialized ItemInstance state in Hazelcast.
You have pinned down the design boundary that keeps behavior local while state remains portable.
Next, you will create the Gradle project with pinned Kotlin, Minestom, and Hazelcast dependencies.
Set Up Kotlin, Minestom, and Hazelcast
The item engine brings Kotlin, Minestom item rendering, plus Hazelcast state into one build. A reproducible toolchain keeps those moving parts compatible.
Your JDK 25 check is complete. Your IntelliJ IDEA 2025.2 check is complete. This step turns those verified tools into a runnable Gradle project.
In this step, get ready to:
- Create the Gradle Kotlin/JVM project.
- Pin the complete build configuration.
- Run the temporary application entry point.
Create the Gradle project
The Gradle Wrapper belongs to the project. It gives every machine the same Gradle version.
- Return to the IntelliJ IDEA window from earlier.
- Click File in the top menu.
- Select New from the menu.
- Select Project.
- Choose Kotlin from the language list.
- Enter distributed-skyblock-items as the project name.
- Set the project location to ~/Desktop/distributed-skyblock-items.
- Select Gradle as the build system.
Why Use the Gradle Wrapper?
The wrapper records which Gradle distribution runs the build. Your project stays reproducible without depending on a separate system-wide Gradle installation.
- Select Kotlin DSL as the build script language.
- Set the JDK field to 25.
- Choose Gradle Wrapper version 9.7.0 as the Gradle distribution.
- Click Create.
Your first milestone is complete. IntelliJ IDEA now has the distributed-skyblock-items scaffold open from your Desktop.
- Select the integrated terminal tool window along the bottom edge of IntelliJ IDEA.
Before you check, which Gradle version do you expect the generated wrapper to report?
- Check the generated Gradle Wrapper version by running this command:
./gradlew --version
What Does This Command Do?
The wrapper launches the Gradle distribution configured inside this project. The version report confirms which distribution your future build commands use.
✔️ I see Gradle 9.7.0
The output lists Gradle 9.7.0. Your project has the required wrapper version.
ⓧ I see an older version
The project has a working wrapper with a different distribution. Update it to the pinned version before editing the build files.
- Update the wrapper to Gradle 9.7.0 by running this command:
./gradlew :wrapper --gradle-version=9.7.0 && ./gradlew :wrapper
What Does This Command Do?
The first wrapper task updates the configured distribution to Gradle 9.7.0. The second wrapper task refreshes the wrapper files using that distribution.
- Confirm the updated version by running the version check again:
./gradlew --version
What Should I See?
The version report now lists Gradle 9.7.0. The wrapper update is complete.
ⓧ The command is not found
The scaffold contains no work beyond the generated files, so recreating it is safe. This gives the wizard another chance to generate the wrapper scripts.
- Return to IntelliJ IDEA's welcome screen using the project-close action in the File menu.
- Delete the distributed-skyblock-items folder from your Desktop using the Linux file manager.
- Repeat the project wizard selections above with Gradle Wrapper 9.7.0 selected.
- Run the wrapper version check again after the recreated project opens.
The settings file names the Gradle project. The build file pins the language plugin, dependencies, toolchain, plus application entry point.
- Select settings.gradle.kts in IntelliJ IDEA's project sidebar.
- Replace its contents with this project setting:
rootProject.name = "distributed-skyblock-items"
What Does This Setting Do?
The root project name identifies this build as distributed-skyblock-items. Gradle uses it when describing the project in build output.
- Save settings.gradle.kts.
The editor now shows one root project assignment for distributed-skyblock-items.
- Select build.gradle.kts in the project sidebar.
- Replace its contents with the pinned build configuration below:
plugins {
kotlin("jvm") version "2.4.20"
application
}
group = "dev.nextwork"
version = "1.0-SNAPSHOT"
repositories {
mavenCentral()
}
dependencies {
implementation("net.minestom:minestom:2026.09.12-26.2")
implementation("com.hazelcast:hazelcast:5.7.0")
}
kotlin {
jvmToolchain(25)
}
application {
mainClass = "dev.nextwork.items.MainKt"
}
What Does This Build Configure?
- The plugins section enables Kotlin/JVM 2.4.20 plus Gradle's application support.
- The repositories section lets Gradle resolve the declared dependencies from Maven Central.
- The dependencies section adds Minestom 2026.09.12-26.2 plus Hazelcast 5.7.0.
- The toolchain section compiles the project with JDK 25.
- The application section points Gradle to dev.nextwork.items.MainKt.
- Save build.gradle.kts.
The first Gradle sync may take a few minutes while the dependencies download. A quiet progress indicator during that download is normal.
- Use IntelliJ IDEA's Gradle reload control in the editor banner.
You'll see the Gradle sync finish without red dependency errors in build.gradle.kts.
Gradle Sync Failing?
Compare each plugin version plus dependency coordinate with the configuration above. A missing quote or parenthesis prevents the Kotlin DSL from loading.
Confirm that IntelliJ IDEA still uses JDK 25 for this project.
Help me fix my Gradle sync.
Add the temporary application entry point
The application plugin needs the configured mainClass to exist. A temporary program gives the build one clear success signal before the item model arrives.
- Expand src/main/kotlin in IntelliJ IDEA's project sidebar.
- Delete the generated src/main/kotlin/Main.kt from the project sidebar if it exists.
- Use IntelliJ IDEA's package creation action to create dev.nextwork.items inside src/main/kotlin.
- Use IntelliJ IDEA's Kotlin file creation action to create Main.kt inside dev.nextwork.items.
- Replace the new file's contents with this temporary entry point:
package dev.nextwork.items
fun main() {
println("SkyBlock item engine ready")
}
What Does This Code Do?
- The package declaration places the compiled entry point at dev.nextwork.items.MainKt.
- The main() function gives the Gradle application task a starting point.
- The printed message provides a visible signal that compilation plus execution succeeded.
- Save src/main/kotlin/dev/nextwork/items/Main.kt.
The first complete build can take a few minutes while Gradle finishes downloading the pinned libraries. The later runs are faster once those files are cached.
Before you run the application, do you expect Gradle to reach the temporary entry point?
- Run the application from the project terminal with this command:
./gradlew run
What Does This Command Do?
The Gradle Application plugin compiles the Kotlin source. It launches the configured dev.nextwork.items.MainKt entry point after compilation succeeds.
You'll see SkyBlock item engine ready followed by BUILD SUCCESSFUL. That green build proves the complete pinned toolchain can run your Kotlin application.
Build Not Reaching the Ready Message?
Check that Main.kt is inside src/main/kotlin/dev/nextwork/items. Its package declaration must match that location.
Compare the configured mainClass with dev.nextwork.items.MainKt exactly.
Help me fix the first Gradle run.
✔️ Awesome, I've got everything!
Your three project files are saved. The Gradle application now runs successfully with the pinned toolchain.
ⓧ I'd like to double check the full code
Compare each file with the complete setup below.
rootProject.name = "distributed-skyblock-items"
plugins {
kotlin("jvm") version "2.4.20"
application
}
group = "dev.nextwork"
version = "1.0-SNAPSHOT"
repositories {
mavenCentral()
}
dependencies {
implementation("net.minestom:minestom:2026.09.12-26.2")
implementation("com.hazelcast:hazelcast:5.7.0")
}
kotlin {
jvmToolchain(25)
}
application {
mainClass = "dev.nextwork.items.MainKt"
}
package dev.nextwork.items
fun main() {
println("SkyBlock item engine ready")
}
Your pinned Kotlin application is running. Next, you'll replace the temporary message with the first registered item plus its calculated lore.
Render the First Registered Item
Your Gradle application already proves the pinned Kotlin toolchain can run. Now it needs an item you can inspect.
An item engine must keep shared definitions separate from unique instance upgrades. A rendered sword with calculated lore proves those layers agree before distribution enters the design.
In this step, get ready to:
- Model item definitions plus unique item instances.
- Calculate final statistics for an upgraded instance.
- Render the Ember Blade as an identifiable Minestom ItemStack.
Create the item model
An item definition describes data shared by every copy. An item instance holds the UUID plus upgrades for one copy.
- In IntelliJ IDEA's Project pane, right-click the dev.nextwork.items package.
- Choose the control for creating a Kotlin file.
- Name the file Model.kt.
You should see Model.kt beside Main.kt in the package.
- Add the package declaration plus the item enums by pasting this code into Model.kt:
package dev.nextwork.items
import net.minestom.server.item.Material
import java.util.UUID
enum class ItemRarity(val label: String) {
COMMON("COMMON"), UNCOMMON("UNCOMMON"), RARE("RARE"), EPIC("EPIC"), LEGENDARY("LEGENDARY")
}
enum class ItemStat(val label: String) {
DAMAGE("Damage"), STRENGTH("Strength")
}
What Does This Model Establish?
- ItemRarity limits every definition to a known display tier.
- ItemStat gives the calculator a fixed set of supported statistics.
- The Material import lets each definition point to a Minestom material.
- The UUID import supports a unique identity for each future instance.
- Save Model.kt.
- Confirm the Material import has no red highlighting.
- Confirm the UUID import has no red highlighting.
Imports Still Showing Red?
Wait for Gradle indexing to finish if the status bar still shows background work. Use IntelliJ IDEA's Gradle reload control if the imports remain unresolved.
Check that Model.kt sits inside src/main/kotlin/dev/nextwork/items.
Help me resolve the Model.kt imports.
- Add the shared definition type one blank line below ItemStat by pasting this code:
data class ItemDefinition(
val id: String, val material: Material, val displayName: String,
val rarity: ItemRarity, val baseStats: Map<ItemStat, Int>, val abilityId: String? = null
)
What Does an Item Definition Hold?
- The id gives the item type a stable registry key.
- The material plus display name control the rendered item.
- Base stats belong to the shared definition.
- The optional ability ID points to behavior that a local registry can provide later.
- Save Model.kt.
- Return to the terminal from the previous step.
- Confirm the current model compiles by running this command:
./gradlew run
What Does This Check Prove?
Gradle compiles every Kotlin source file before running the application. A successful run proves the new enums plus ItemDefinition fit the existing project.
You should still see SkyBlock item engine ready followed by BUILD SUCCESSFUL.
Model Compilation Failing?
Check that each enum closes before ItemDefinition begins. Confirm the file starts with the exact dev.nextwork.items package.
Help me fix the first Model.kt compilation error.
- Add the unique instance type one blank line below ItemDefinition by pasting this code:
data class ItemInstance(
val uniqueId: UUID, val definitionId: String, val upgrades: Map<ItemStat, Int> = emptyMap()
) {
fun encode(): String {
val encodedUpgrades = upgrades.entries.sortedBy { it.key.name }
.joinToString(",") { "${it.key.name}:${it.value}" }
return "$uniqueId|$definitionId|$encodedUpgrades"
}
companion object {
fun decode(encoded: String): ItemInstance {
val parts = encoded.split("|", limit = 3)
require(parts.size == 3) { "Invalid item payload" }
val upgrades = parts[2].takeIf { it.isNotBlank() }?.split(",")?.associate { token ->
val pair = token.split(":", limit = 2)
require(pair.size == 2) { "Invalid upgrade payload" }
ItemStat.valueOf(pair[0]) to pair[1].toInt()
}.orEmpty()
return ItemInstance(UUID.fromString(parts[0]), parts[1], upgrades)
}
}
}
How Does Instance Encoding Work?
- ItemInstance stores the unique ID of one item copy.
- The definition ID links that copy back to shared data.
- The upgrades map stores only per-instance statistic changes.
- Encoding sorts upgrade keys before producing a stable string payload.
- Save Model.kt.
- Confirm the complete model compiles by running this command:
./gradlew run
What Does the Second Model Check Prove?
This run compiles the new encoding plus decoding logic. The temporary main function still supplies the visible output.
You should see SkyBlock item engine ready followed by BUILD SUCCESSFUL.
Encoding Logic Not Compiling?
Check the braces around encode() plus the companion object. Confirm the split delimiters remain inside quotation marks.
Help me debug ItemInstance encoding.
Build the registry, calculator, and renderer
The registry resolves stable IDs into definitions. The calculator combines a definition's base values with one instance's upgrades.
The renderer turns that result into an immutable Minestom ItemStack. A hidden custom item tag keeps the instance identity attached to the rendered item.
- In IntelliJ IDEA's Project pane, right-click the dev.nextwork.items package.
- Choose the control for creating a Kotlin file.
- Name the file ItemEngine.kt.
You should see ItemEngine.kt beside the model file you just completed.
- Add the required imports plus the item registry by pasting this code into ItemEngine.kt:
package dev.nextwork.items
import net.kyori.adventure.text.Component
import net.kyori.adventure.text.format.NamedTextColor
import net.minestom.server.component.DataComponents
import net.minestom.server.item.ItemStack
import net.minestom.server.tag.Tag
import java.util.UUID
class ItemRegistry {
private val definitions = linkedMapOf<String, ItemDefinition>()
fun register(definition: ItemDefinition) {
require(definitions.putIfAbsent(definition.id, definition) == null) { "Duplicate item definition: ${definition.id}" }
}
operator fun get(id: String): ItemDefinition = definitions[id] ?: error("Unknown item definition: $id")
}
Why Use a Registry?
- ItemRegistry keeps one local definition for each stable item ID.
- Registration rejects duplicate IDs before one definition can replace another.
- The lookup operator reports an unknown ID immediately.
- Save ItemEngine.kt.
- Confirm the registry compiles by running this command:
./gradlew run
What Does the Registry Check Prove?
Gradle now compiles the registry against the types in Model.kt. A successful run proves both files share the same package.
You should see SkyBlock item engine ready followed by BUILD SUCCESSFUL.
Registry Types Unresolved?
Confirm Model.kt has been saved. Check that both files begin with the same package declaration.
Help me connect ItemRegistry to Model.kt.
- Add the statistic calculator one blank line below ItemRegistry by pasting this code:
object StatCalculator {
fun calculate(definition: ItemDefinition, instance: ItemInstance): Map<ItemStat, Int> {
require(instance.definitionId == definition.id) { "Instance ${instance.uniqueId} does not match ${definition.id}" }
return ItemStat.entries.mapNotNull { stat ->
val total = definition.baseStats.getOrDefault(stat, 0) + instance.upgrades.getOrDefault(stat, 0)
if (total == 0) null else stat to total
}.toMap()
}
}
How Are Final Statistics Calculated?
- The identity check prevents an instance from using the wrong definition.
- Each supported stat receives its base value plus its upgrade value.
- Zero-value statistics stay out of the final map.
- Save ItemEngine.kt.
- Confirm IntelliJ IDEA resolves ItemStat.entries without red highlighting.
Calculator Showing a Type Error?
Check that baseStats plus upgrades both use Map<ItemStat, Int> in Model.kt.
Help me fix the StatCalculator type error.
- Add the immutable item renderer one blank line below StatCalculator by pasting this code:
object ItemRenderer {
private val instanceIdTag = Tag.UUID("skyblock_item_instance")
fun render(definition: ItemDefinition, instance: ItemInstance): ItemStack {
val stats = StatCalculator.calculate(definition, instance)
val lore = buildList<Component> {
stats.forEach { (stat, value) -> add(Component.text("${stat.label}: +$value", NamedTextColor.GRAY)) }
definition.abilityId?.let { abilityId -> add(Component.empty()); add(Component.text("Ability: $abilityId", NamedTextColor.GOLD)) }
add(Component.empty())
add(Component.text(definition.rarity.label, rarityColor(definition.rarity)))
}
return ItemStack.builder(definition.material)
.set(DataComponents.CUSTOM_NAME, Component.text(definition.displayName, rarityColor(definition.rarity)))
.set(DataComponents.LORE, lore).set(instanceIdTag, instance.uniqueId).build()
}
fun readInstanceId(itemStack: ItemStack): UUID? = itemStack.getTag(instanceIdTag)
private fun rarityColor(rarity: ItemRarity) = when (rarity) {
ItemRarity.COMMON -> NamedTextColor.WHITE
ItemRarity.UNCOMMON -> NamedTextColor.GREEN
ItemRarity.RARE -> NamedTextColor.BLUE
ItemRarity.EPIC -> NamedTextColor.DARK_PURPLE
ItemRarity.LEGENDARY -> NamedTextColor.GOLD
}
}
How Does Rendering Preserve Identity?
- The renderer calculates statistics from the definition plus the instance.
- Item data components hold the custom name plus the generated lore.
- The UUID tag stores the instance identity inside the finished ItemStack.
- The builder produces a complete immutable item value.
- Save ItemEngine.kt.
- Confirm the complete engine compiles by running this command:
./gradlew run
What Does the Engine Check Prove?
This run compiles the Minestom builder calls plus the custom UUID tag. It also checks every rarity color branch.
You should see SkyBlock item engine ready followed by BUILD SUCCESSFUL.
Renderer Not Compiling?
Check the imports at the top of ItemEngine.kt against the code block. Confirm every ItemRarity value appears in the color mapping.
Help me debug ItemRenderer.
Register and render the Ember Blade
The engine now has its reusable layers. The temporary main function can become a console demonstration that exercises the complete path.
- Switch back to Main.kt from the previous step.
- Select the temporary main function plus its existing package declaration.
- Replace the selected content with this code:
package dev.nextwork.items
import net.minestom.server.item.Material
import java.util.UUID
fun main() {
val definitions = ItemRegistry()
val emberBlade = ItemDefinition(
id = "EMBER_BLADE", material = Material.DIAMOND_SWORD,
displayName = "Ember Blade", rarity = ItemRarity.RARE,
baseStats = mapOf(ItemStat.DAMAGE to 75, ItemStat.STRENGTH to 20), abilityId = "ember_burst"
)
definitions.register(emberBlade)
val upgradedItem = ItemInstance(UUID.randomUUID(), emberBlade.id, mapOf(ItemStat.DAMAGE to 15))
val initialStats = StatCalculator.calculate(emberBlade, upgradedItem)
val renderedItem = ItemRenderer.render(emberBlade, upgradedItem)
println("Rendered item: ${emberBlade.displayName}")
println("Material: ${renderedItem.material()}")
println("Final stats: $initialStats")
println("Embedded item id: ${ItemRenderer.readInstanceId(renderedItem)}")
check(ItemRenderer.readInstanceId(renderedItem) == upgradedItem.uniqueId)
}
What Does the Demonstration Do?
- The definition registers the Ember Blade with base damage of 75 plus strength of 20.
- The unique instance adds a 15-point damage upgrade.
- The calculator produces final damage of 90 plus final strength of 20.
- The final check proves the rendered UUID matches the instance UUID.
- Save Main.kt.
Before you run the demonstration, what final damage do you expect after the 15-point upgrade is added to the base value?
- Run the completed item demonstration with this command:
./gradlew run
What Does the Final Run Verify?
The Gradle application creates one upgraded Ember Blade. It then renders the Minestom ItemStack before reading its hidden UUID tag.
You should see Rendered item: Ember Blade plus Material: minecraft:diamond_sword.
The final statistics contain DAMAGE=90 plus STRENGTH=20. The embedded item ID is a generated UUID.
The run finishes with BUILD SUCCESSFUL because the identity check passes.
Rendered Output Missing?
Check the first compiler error before reviewing later messages. One missing bracket can create several follow-on errors.
If the statistics differ, confirm the base damage is 75 plus the instance upgrade is 15.
Help me fix the Ember Blade demonstration.
You have it. The Ember Blade now carries calculated statistics plus a recoverable identity inside its rendered ItemStack.
✔️ Awesome, I've got everything!
Great. Double-check that all three Kotlin files are saved.
Your latest run should finish with BUILD SUCCESSFUL.
ⓧ I'd like to double check the full code
Compare settings.gradle.kts:
rootProject.name = "distributed-skyblock-items"
Compare build.gradle.kts:
plugins {
kotlin("jvm") version "2.4.20"
application
}
group = "dev.nextwork"
version = "1.0-SNAPSHOT"
repositories {
mavenCentral()
}
dependencies {
implementation("net.minestom:minestom:2026.09.12-26.2")
implementation("com.hazelcast:hazelcast:5.7.0")
}
kotlin {
jvmToolchain(25)
}
application {
mainClass = "dev.nextwork.items.MainKt"
}
Compare Model.kt:
package dev.nextwork.items
import net.minestom.server.item.Material
import java.util.UUID
enum class ItemRarity(val label: String) {
COMMON("COMMON"), UNCOMMON("UNCOMMON"), RARE("RARE"), EPIC("EPIC"), LEGENDARY("LEGENDARY")
}
enum class ItemStat(val label: String) {
DAMAGE("Damage"), STRENGTH("Strength")
}
data class ItemDefinition(
val id: String, val material: Material, val displayName: String,
val rarity: ItemRarity, val baseStats: Map<ItemStat, Int>, val abilityId: String? = null
)
data class ItemInstance(
val uniqueId: UUID, val definitionId: String, val upgrades: Map<ItemStat, Int> = emptyMap()
) {
fun encode(): String {
val encodedUpgrades = upgrades.entries.sortedBy { it.key.name }
.joinToString(",") { "${it.key.name}:${it.value}" }
return "$uniqueId|$definitionId|$encodedUpgrades"
}
companion object {
fun decode(encoded: String): ItemInstance {
val parts = encoded.split("|", limit = 3)
require(parts.size == 3) { "Invalid item payload" }
val upgrades = parts[2].takeIf { it.isNotBlank() }?.split(",")?.associate { token ->
val pair = token.split(":", limit = 2)
require(pair.size == 2) { "Invalid upgrade payload" }
ItemStat.valueOf(pair[0]) to pair[1].toInt()
}.orEmpty()
return ItemInstance(UUID.fromString(parts[0]), parts[1], upgrades)
}
}
}
Compare ItemEngine.kt:
package dev.nextwork.items
import net.kyori.adventure.text.Component
import net.kyori.adventure.text.format.NamedTextColor
import net.minestom.server.component.DataComponents
import net.minestom.server.item.ItemStack
import net.minestom.server.tag.Tag
import java.util.UUID
class ItemRegistry {
private val definitions = linkedMapOf<String, ItemDefinition>()
fun register(definition: ItemDefinition) {
require(definitions.putIfAbsent(definition.id, definition) == null) { "Duplicate item definition: ${definition.id}" }
}
operator fun get(id: String): ItemDefinition = definitions[id] ?: error("Unknown item definition: $id")
}
object StatCalculator {
fun calculate(definition: ItemDefinition, instance: ItemInstance): Map<ItemStat, Int> {
require(instance.definitionId == definition.id) { "Instance ${instance.uniqueId} does not match ${definition.id}" }
return ItemStat.entries.mapNotNull { stat ->
val total = definition.baseStats.getOrDefault(stat, 0) + instance.upgrades.getOrDefault(stat, 0)
if (total == 0) null else stat to total
}.toMap()
}
}
object ItemRenderer {
private val instanceIdTag = Tag.UUID("skyblock_item_instance")
fun render(definition: ItemDefinition, instance: ItemInstance): ItemStack {
val stats = StatCalculator.calculate(definition, instance)
val lore = buildList<Component> {
stats.forEach { (stat, value) -> add(Component.text("${stat.label}: +$value", NamedTextColor.GRAY)) }
definition.abilityId?.let { abilityId -> add(Component.empty()); add(Component.text("Ability: $abilityId", NamedTextColor.GOLD)) }
add(Component.empty())
add(Component.text(definition.rarity.label, rarityColor(definition.rarity)))
}
return ItemStack.builder(definition.material)
.set(DataComponents.CUSTOM_NAME, Component.text(definition.displayName, rarityColor(definition.rarity)))
.set(DataComponents.LORE, lore).set(instanceIdTag, instance.uniqueId).build()
}
fun readInstanceId(itemStack: ItemStack): UUID? = itemStack.getTag(instanceIdTag)
private fun rarityColor(rarity: ItemRarity) = when (rarity) {
ItemRarity.COMMON -> NamedTextColor.WHITE
ItemRarity.UNCOMMON -> NamedTextColor.GREEN
ItemRarity.RARE -> NamedTextColor.BLUE
ItemRarity.EPIC -> NamedTextColor.DARK_PURPLE
ItemRarity.LEGENDARY -> NamedTextColor.GOLD
}
}
Compare Main.kt:
package dev.nextwork.items
import net.minestom.server.item.Material
import java.util.UUID
fun main() {
val definitions = ItemRegistry()
val emberBlade = ItemDefinition(
id = "EMBER_BLADE", material = Material.DIAMOND_SWORD,
displayName = "Ember Blade", rarity = ItemRarity.RARE,
baseStats = mapOf(ItemStat.DAMAGE to 75, ItemStat.STRENGTH to 20), abilityId = "ember_burst"
)
definitions.register(emberBlade)
val upgradedItem = ItemInstance(UUID.randomUUID(), emberBlade.id, mapOf(ItemStat.DAMAGE to 15))
val initialStats = StatCalculator.calculate(emberBlade, upgradedItem)
val renderedItem = ItemRenderer.render(emberBlade, upgradedItem)
println("Rendered item: ${emberBlade.displayName}")
println("Material: ${renderedItem.material()}")
println("Final stats: $initialStats")
println("Embedded item id: ${ItemRenderer.readInstanceId(renderedItem)}")
check(ItemRenderer.readInstanceId(renderedItem) == upgradedItem.uniqueId)
}
Your item engine can now register one definition plus render a uniquely upgraded copy. Next, you will test what happens when a second local repository tries to find that same instance.
Expose the Local-State Failure
Your Ember Blade now renders with upgraded statistics. Its hidden identity survives inside the Minestom ItemStack.
A multi-server item engine must answer a harder question. Can a second server retrieve state that the first server saved?
In this step, you'll put both simulated servers behind the same repository contract. Then you'll test a cross-server lookup with the current local implementation.
In this step, get ready to:
- Define a common repository contract for saving and finding item instances.
- Create independent local repositories for two simulated servers.
- Run a cross-server lookup to inspect how local state behaves.
Create the local repository boundary
A repository abstraction gives the item engine one contract for storing instance state. The local implementation keeps each instance in a map keyed by its UUID.
- Create Repositories.kt inside src/main/kotlin/dev/nextwork/items using the new-file control in IntelliJ IDEA.
- Replace the new file's contents with the code below:
package dev.nextwork.items
import java.util.UUID
interface ItemRepository {
fun save(item: ItemInstance)
fun find(uniqueId: UUID): ItemInstance?
}
class LocalItemRepository : ItemRepository {
private val items = mutableMapOf<UUID, ItemInstance>()
override fun save(item: ItemInstance) { items[item.uniqueId] = item }
override fun find(uniqueId: UUID): ItemInstance? = items[uniqueId]
}
What does this repository do?
- The ItemRepository interface defines the operations that every storage implementation must support.
- The save() function stores an ItemInstance under its UUID.
- The find() function returns the matching instance when that UUID exists in the map.
- Each LocalItemRepository object owns its own items map.
- Save Repositories.kt.
You should see Repositories.kt beside Main.kt in the dev/nextwork/items package.
Repository types not resolving?
Confirm that Repositories.kt starts with package dev.nextwork.items. This places the repository beside ItemInstance.
Check that both occurrences of UUID use the import at the top of the file.
Help me fix my local repository code.
✔️ Awesome, I've got everything!
Great. Double check that Repositories.kt is saved inside the dev/nextwork/items package.
ⓧ I'd like to double check the full code
Your complete Repositories.kt file should look like this:
package dev.nextwork.items
import java.util.UUID
interface ItemRepository {
fun save(item: ItemInstance)
fun find(uniqueId: UUID): ItemInstance?
}
class LocalItemRepository : ItemRepository {
private val items = mutableMapOf<UUID, ItemInstance>()
override fun save(item: ItemInstance) { items[item.uniqueId] = item }
override fun find(uniqueId: UUID): ItemInstance? = items[uniqueId]
}
Test the second server lookup
A cross-server lookup uses the same item UUID on both simulated servers. Two LocalItemRepository objects let you test whether the current storage boundary can support that lookup.
- Switch back to Main.kt from earlier.
- Find the line containing check(ItemRenderer.readInstanceId(renderedItem) == upgradedItem.uniqueId).
- Insert the following code below that line:
val localServerA = LocalItemRepository()
val localServerB = LocalItemRepository()
localServerA.save(upgradedItem)
val missingItem = localServerB.find(upgradedItem.uniqueId)
println("Local repository on server B: ${missingItem?.definitionId ?: "missing"}")
check(missingItem == null)
How does this test work?
- The localServerA and localServerB variables represent separate server processes.
- The call to localServerA.save() stores the upgraded item through the first repository.
- The call to localServerB.find() requests the same UUID through the second repository.
- The check() call turns the result into an executable architecture test.
- Save Main.kt.
Before you run this, do you think server B can retrieve the item saved by server A?
- Test the cross-server lookup by running:
./gradlew run
What should I see?
You'll see Local repository on server B: missing after the rendered item details.
You'll also see BUILD SUCCESSFUL. The missingItem == null check passes because the application observed the intended local-state shortfall.
You have now made the multi-server limitation visible with a repeatable test.
Build stopping before the lookup?
Confirm that the new repository statements sit inside the closing brace of main().
Compare the names localServerA, localServerB, and missingItem with the code above.
Help me debug the local-state test.
✔️ Awesome, I've got everything!
Your local-state test is complete. Keep the successful terminal output available for the checkpoint below.
ⓧ I'd like to double check the full code
Your complete Main.kt file should look like this:
package dev.nextwork.items
import net.minestom.server.item.Material
import java.util.UUID
fun main() {
val definitions = ItemRegistry()
val emberBlade = ItemDefinition(
id = "EMBER_BLADE", material = Material.DIAMOND_SWORD,
displayName = "Ember Blade", rarity = ItemRarity.RARE,
baseStats = mapOf(ItemStat.DAMAGE to 75, ItemStat.STRENGTH to 20), abilityId = "ember_burst"
)
definitions.register(emberBlade)
val upgradedItem = ItemInstance(UUID.randomUUID(), emberBlade.id, mapOf(ItemStat.DAMAGE to 15))
val initialStats = StatCalculator.calculate(emberBlade, upgradedItem)
val renderedItem = ItemRenderer.render(emberBlade, upgradedItem)
println("Rendered item: ${emberBlade.displayName}")
println("Material: ${renderedItem.material()}")
println("Final stats: $initialStats")
println("Embedded item id: ${ItemRenderer.readInstanceId(renderedItem)}")
check(ItemRenderer.readInstanceId(renderedItem) == upgradedItem.uniqueId)
val localServerA = LocalItemRepository()
val localServerB = LocalItemRepository()
localServerA.save(upgradedItem)
val missingItem = localServerB.find(upgradedItem.uniqueId)
println("Local repository on server B: ${missingItem?.definitionId ?: "missing"}")
check(missingItem == null)
}
You have proved that isolated maps leave server B without the upgraded item. Next, Hazelcast gives both simulated servers access to the same serialized instance state.
Share Item Instances Through Hazelcast
Your local repository test proved that server B cannot retrieve the upgraded Ember Blade from server A. The item engine now needs a shared state layer that both simulated servers can reach.
Hazelcast gives both members access to one distributed IMap. Serialization keeps the shared record limited to the item UUID, definition ID, and upgrades. Definitions remain local. Executable behavior remains local.
In this step, get ready to:
- Store encoded item instances in a Hazelcast repository.
- Connect two embedded Hazelcast members to one named IMap.
- Rebuild the shared item as a Minestom ItemStack with its original UUID.
Store serialized instances in an IMap
The repository interface lets local storage and distributed storage expose the same operations. The Hazelcast implementation stores encoded strings so the cluster never receives definitions or executable objects.
- Replace the contents of Repositories.kt with the following code:
package dev.nextwork.items
import com.hazelcast.map.IMap
import java.util.UUID
interface ItemRepository {
fun save(item: ItemInstance)
fun find(uniqueId: UUID): ItemInstance?
}
class LocalItemRepository : ItemRepository {
private val items = mutableMapOf<UUID, ItemInstance>()
override fun save(item: ItemInstance) { items[item.uniqueId] = item }
override fun find(uniqueId: UUID): ItemInstance? = items[uniqueId]
}
class HazelcastItemRepository(private val items: IMap<String, String>) : ItemRepository {
override fun save(item: ItemInstance) { items[item.uniqueId.toString()] = item.encode() }
override fun find(uniqueId: UUID): ItemInstance? = items[uniqueId.toString()]?.let(ItemInstance::decode)
}
What does this repository do?
- The IMap<String, String> stores each UUID key beside one encoded item payload.
- The save() function converts an ItemInstance into its deterministic string representation.
- The find() function reconstructs the matching instance with ItemInstance.decode().
- The shared map contains state only. Item definitions stay inside each server process.
- Save Repositories.kt.
- Confirm the new repository compiles by running this command:
./gradlew run
What should you see?
The application should finish with the existing rendered-item output. You should still see Local repository on server B: missing because Main.kt still uses the two local repositories.
Good progress. Your distributed repository now compiles behind the same interface as the local implementation.
Repository not compiling?
Confirm that the Hazelcast import appears above the existing UUID import. Check that the map uses String for both its key and value types.
Compare the method signatures with ItemRepository. Both implementations must accept the same item and UUID types.
Help me fix my Hazelcast repository.
✔️ Awesome, I've got everything!
Great. Double check that Repositories.kt is saved and that the application still runs.
ⓧ I'd like to double check the full code
Your complete Repositories.kt file should look like this:
package dev.nextwork.items
import com.hazelcast.map.IMap
import java.util.UUID
interface ItemRepository {
fun save(item: ItemInstance)
fun find(uniqueId: UUID): ItemInstance?
}
class LocalItemRepository : ItemRepository {
private val items = mutableMapOf<UUID, ItemInstance>()
override fun save(item: ItemInstance) { items[item.uniqueId] = item }
override fun find(uniqueId: UUID): ItemInstance? = items[uniqueId]
}
class HazelcastItemRepository(private val items: IMap<String, String>) : ItemRepository {
override fun save(item: ItemInstance) { items[item.uniqueId.toString()] = item.encode() }
override fun find(uniqueId: UUID): ItemInstance? = items[uniqueId.toString()]?.let(ItemInstance::decode)
}
Prepare Main for embedded members
The application needs Hazelcast's configuration and member factory before it can form a local cluster. Adding these imports first gives Gradle a quick chance to confirm that the pinned dependency is available.
- In Main.kt, find the current import block:
import net.minestom.server.item.Material
import java.util.UUID
What are you locating?
These imports support the item material and generated instance UUID. The Hazelcast imports belong directly above them.
- Replace that import block with the following version:
import com.hazelcast.config.Config
import com.hazelcast.core.Hazelcast
import net.minestom.server.item.Material
import java.util.UUID
What do the new imports provide?
- The Config type holds the cluster name used by both members.
- The Hazelcast factory creates embedded members inside the running application.
- Save Main.kt.
- Confirm that Gradle resolves the Hazelcast imports by running this command:
./gradlew run
What does this check prove?
The application should complete with the same local-repository demonstration. A successful build confirms that Config and Hazelcast resolve from the pinned dependency.
Hazelcast imports unresolved?
Return to build.gradle.kts and confirm that the pinned Hazelcast dependency remains inside dependencies. Refresh the Gradle project in IntelliJ IDEA if the editor still marks the imports as unresolved.
Help me resolve the Hazelcast imports.
Share and rebuild the item
Both embedded members use the same cluster name and map name. Member A writes the encoded instance. Member B reads that state before resolving the definition from its local registry.
- In Main.kt, find the line check(missingItem == null) near the end of main().
- Paste the following code below that line and before the final closing brace:
val config = Config().setClusterName("nextwork-skyblock-items")
val memberA = Hazelcast.newHazelcastInstance(config)
val memberB = Hazelcast.newHazelcastInstance(config)
try {
val distributedServerA = HazelcastItemRepository(
memberA.getMap<String, String>("skyblock-item-instances")
)
val distributedServerB = HazelcastItemRepository(
memberB.getMap<String, String>("skyblock-item-instances")
)
distributedServerA.save(upgradedItem)
val sharedItem = checkNotNull(distributedServerB.find(upgradedItem.uniqueId))
val sharedDefinition = definitions[sharedItem.definitionId]
val sharedStats = StatCalculator.calculate(sharedDefinition, sharedItem)
val rebuiltItem = ItemRenderer.render(sharedDefinition, sharedItem)
println("Hazelcast repository on server B: ${sharedItem.definitionId}")
println(
"Rebuilt item id matches: " +
(ItemRenderer.readInstanceId(rebuiltItem) == upgradedItem.uniqueId)
)
check(ItemRenderer.readInstanceId(rebuiltItem) == upgradedItem.uniqueId)
} finally {
memberB.shutdown()
memberA.shutdown()
}
How does the distributed flow work?
- The shared cluster name lets memberA and memberB join the same embedded cluster.
- Both repositories request skyblock-item-instances, so they address the same distributed map.
- Member B decodes sharedItem before the local registry resolves EMBER_BLADE.
- The finally block shuts down both members even if a validation fails.
- Save Main.kt.
Before you run the complete flow, do you expect member B to recover the same item UUID that member A stored?
- Run the distributed item demonstration with this command:
./gradlew run
What should you see?
Hazelcast prints startup logs while the two members form their local cluster. After those logs, you should see Hazelcast repository on server B: EMBER_BLADE.
You should also see Rebuilt item id matches: true. This proves that member B rebuilt a Minestom ItemStack carrying the UUID created before distribution.
Shared item missing or UUID check failing?
Confirm that both members receive the same config value. Confirm that both repositories request skyblock-item-instances with identical spelling.
Check that distributedServerA.save(upgradedItem) runs before member B calls find().
Help me debug the distributed item lookup.
✔️ Awesome, I've got everything!
Excellent. Member B now retrieves the shared Ember Blade and rebuilds its original identity.
ⓧ I'd like to double check the full code
Your cumulative Main.kt file should look like this:
package dev.nextwork.items
import com.hazelcast.config.Config
import com.hazelcast.core.Hazelcast
import net.minestom.server.item.Material
import java.util.UUID
fun main() {
val definitions = ItemRegistry()
val emberBlade = ItemDefinition(
id = "EMBER_BLADE",
material = Material.DIAMOND_SWORD,
displayName = "Ember Blade",
rarity = ItemRarity.RARE,
baseStats = mapOf(
ItemStat.DAMAGE to 75,
ItemStat.STRENGTH to 20
),
abilityId = "ember_burst"
)
definitions.register(emberBlade)
val upgradedItem = ItemInstance(
uniqueId = UUID.randomUUID(),
definitionId = emberBlade.id,
upgrades = mapOf(ItemStat.DAMAGE to 15)
)
val initialStats = StatCalculator.calculate(emberBlade, upgradedItem)
val renderedItem = ItemRenderer.render(emberBlade, upgradedItem)
println("Rendered item: ${emberBlade.displayName}")
println("Material: ${renderedItem.material()}")
println("Final stats: $initialStats")
println("Embedded item id: ${ItemRenderer.readInstanceId(renderedItem)}")
check(ItemRenderer.readInstanceId(renderedItem) == upgradedItem.uniqueId)
val localServerA = LocalItemRepository()
val localServerB = LocalItemRepository()
localServerA.save(upgradedItem)
val missingItem = localServerB.find(upgradedItem.uniqueId)
println("Local repository on server B: ${missingItem?.definitionId ?: "missing"}")
check(missingItem == null)
val config = Config().setClusterName("nextwork-skyblock-items")
val memberA = Hazelcast.newHazelcastInstance(config)
val memberB = Hazelcast.newHazelcastInstance(config)
try {
val distributedServerA = HazelcastItemRepository(
memberA.getMap<String, String>("skyblock-item-instances")
)
val distributedServerB = HazelcastItemRepository(
memberB.getMap<String, String>("skyblock-item-instances")
)
distributedServerA.save(upgradedItem)
val sharedItem = checkNotNull(distributedServerB.find(upgradedItem.uniqueId))
val sharedDefinition = definitions[sharedItem.definitionId]
val sharedStats = StatCalculator.calculate(sharedDefinition, sharedItem)
val rebuiltItem = ItemRenderer.render(sharedDefinition, sharedItem)
println("Hazelcast repository on server B: ${sharedItem.definitionId}")
println(
"Rebuilt item id matches: " +
(ItemRenderer.readInstanceId(rebuiltItem) == upgradedItem.uniqueId)
)
check(ItemRenderer.readInstanceId(rebuiltItem) == upgradedItem.uniqueId)
} finally {
memberB.shutdown()
memberA.shutdown()
}
}
Your second server can now reconstruct the same upgraded item from shared state. Next, you will connect that distributed data to locally registered executable behavior.
Rebuild and Activate the Distributed Item
Your two Hazelcast members can already exchange an encoded item instance. Server B rebuilds the same Minestom ItemStack. It also verifies the item's UUID.
Distributed state becomes gameplay when another server combines shared item data with local definitions. Executable behavior completes that combination. In this step, you will register Ember Burst locally. You will then activate it for the rebuilt item.
In this step, get ready to:
- Define the data passed into an item ability.
- Register Ember Burst as local executable behavior.
- Activate the ability from distributed item state.
Define the local ability layer
An ability contract describes the data a behavior receives. It also defines the operation that every ability exposes. The implementation stays local to each server member.
- In ItemEngine.kt, add the ability context and activation contract at the bottom of the file by copying this code:
data class AbilityContext(
val actor: String,
val item: ItemInstance,
val stats: Map<ItemStat, Int>
)
fun interface ItemAbility {
fun activate(context: AbilityContext): String
}
What Do These Types Represent?
- The AbilityContext groups the actor name with the item instance and its calculated statistics.
- The ItemAbility contract gives every local behavior one activate() operation.
- The contract returns a string so this console demonstration can show the gameplay result directly.
A local ability registry connects a stable ability ID to executable behavior. Item definitions can carry the ID while the lambda remains inside the running server.
- Add the registry below ItemAbility by copying this code:
class AbilityRegistry {
private val abilities = mutableMapOf<String, ItemAbility>()
fun register(id: String, ability: ItemAbility) {
require(abilities.putIfAbsent(id, ability) == null) {
"Duplicate ability: $id"
}
}
fun activate(id: String, context: AbilityContext): String =
(abilities[id] ?: error("Unknown ability: $id")).activate(context)
}
How Does the Registry Work?
- The abilities map stores each local ItemAbility under a string ID.
- The register() function rejects duplicate IDs before one behavior can overwrite another.
- The activate() function resolves the requested behavior. It passes the supplied context into that behavior.
- Save ItemEngine.kt.
- Confirm the new ability layer compiles by running this command from the distributed-skyblock-items project directory:
./gradlew run
The ./gradlew run command recompiles the project with the new ability types. You will see the existing distributed item checks complete. The run finishes with BUILD SUCCESSFUL.
Build Failing After the Ability Types?
Check that AbilityContext, ItemAbility, and AbilityRegistry sit after the closing brace of ItemRenderer.
Confirm every type remains inside the dev.nextwork.items package. A misplaced brace can accidentally nest a type inside another object.
Ask for help with the compiler output: Help me debug my Kotlin ability types.
✔️ Awesome, I've got everything!
Great. Your local ability contracts and registry now compile alongside the existing item engine.
ⓧ I'd like to double check the full code
Your complete ItemEngine.kt file should now match this reference.
package dev.nextwork.items
import net.kyori.adventure.text.Component
import net.kyori.adventure.text.format.NamedTextColor
import net.minestom.server.component.DataComponents
import net.minestom.server.item.ItemStack
import net.minestom.server.tag.Tag
import java.util.UUID
class ItemRegistry {
private val definitions = linkedMapOf<String, ItemDefinition>()
fun register(definition: ItemDefinition) {
require(definitions.putIfAbsent(definition.id, definition) == null) {
"Duplicate item definition: ${definition.id}"
}
}
operator fun get(id: String): ItemDefinition =
definitions[id] ?: error("Unknown item definition: $id")
}
object StatCalculator {
fun calculate(
definition: ItemDefinition,
instance: ItemInstance
): Map<ItemStat, Int> {
require(instance.definitionId == definition.id) {
"Instance ${instance.uniqueId} does not match ${definition.id}"
}
return ItemStat.entries.mapNotNull { stat ->
val total = definition.baseStats.getOrDefault(stat, 0) +
instance.upgrades.getOrDefault(stat, 0)
if (total == 0) null else stat to total
}.toMap()
}
}
object ItemRenderer {
private val instanceIdTag = Tag.UUID("skyblock_item_instance")
fun render(definition: ItemDefinition, instance: ItemInstance): ItemStack {
val stats = StatCalculator.calculate(definition, instance)
val lore = buildList<Component> {
stats.forEach { (stat, value) ->
add(Component.text("${stat.label}: +$value", NamedTextColor.GRAY))
}
definition.abilityId?.let { abilityId ->
add(Component.empty())
add(Component.text("Ability: $abilityId", NamedTextColor.GOLD))
}
add(Component.empty())
add(Component.text(definition.rarity.label, rarityColor(definition.rarity)))
}
return ItemStack.builder(definition.material)
.set(
DataComponents.CUSTOM_NAME,
Component.text(definition.displayName, rarityColor(definition.rarity))
)
.set(DataComponents.LORE, lore)
.set(instanceIdTag, instance.uniqueId)
.build()
}
fun readInstanceId(itemStack: ItemStack): UUID? =
itemStack.getTag(instanceIdTag)
private fun rarityColor(rarity: ItemRarity) = when (rarity) {
ItemRarity.COMMON -> NamedTextColor.WHITE
ItemRarity.UNCOMMON -> NamedTextColor.GREEN
ItemRarity.RARE -> NamedTextColor.BLUE
ItemRarity.EPIC -> NamedTextColor.DARK_PURPLE
ItemRarity.LEGENDARY -> NamedTextColor.GOLD
}
}
data class AbilityContext(
val actor: String,
val item: ItemInstance,
val stats: Map<ItemStat, Int>
)
fun interface ItemAbility {
fun activate(context: AbilityContext): String
}
class AbilityRegistry {
private val abilities = mutableMapOf<String, ItemAbility>()
fun register(id: String, ability: ItemAbility) {
require(abilities.putIfAbsent(id, ability) == null) {
"Duplicate ability: $id"
}
}
fun activate(id: String, context: AbilityContext): String =
(abilities[id] ?: error("Unknown ability: $id")).activate(context)
}
- Compare the package declaration, imports, existing item engine, and new ability layer against your file.
Register the Ember Burst behavior
The registry needs one local implementation for the ember_burst ID already stored in the Ember Blade definition. Its lambda reads the final statistics from AbilityContext.
- In Main.kt, find this line near the start of main():
val definitions = ItemRegistry()
What Are You Changing?
The existing definitions registry resolves static item data. The next edit places a separate behavior registry beside it.
- Replace that line with these two lines:
val definitions = ItemRegistry()
val abilities = AbilityRegistry()
Why Use Separate Registries?
The definitions registry stores static item information. The abilities registry stores executable behavior.
This boundary keeps behavior out of the distributed IMap. Hazelcast continues to share only encoded ItemInstance data.
- Find definitions.register(emberBlade) in Main.kt.
- Add the local Ember Burst registration directly below it by copying this code:
abilities.register(
"ember_burst",
ItemAbility { context ->
val damage = context.stats.getOrDefault(ItemStat.DAMAGE, 0)
val strength = context.stats.getOrDefault(ItemStat.STRENGTH, 0)
"${context.actor} releases Ember Burst with $damage damage and $strength strength."
}
)
What Does This Behavior Do?
- The ember_burst ID matches the abilityId stored in the local Ember Blade definition.
- The lambda reads DAMAGE from the calculated statistics.
- The lambda reads STRENGTH from the same calculated statistics.
- The returned string combines the actor with the two final stat values.
- Save Main.kt.
- Confirm the local behavior registration compiles by running this command:
./gradlew run
The ./gradlew run command executes the locally registered lambda setup. You will see the distributed repository output reach Rebuilt item id matches: true before the build succeeds.
Registration Not Compiling?
Confirm val abilities = AbilityRegistry() appears inside main(). It must appear before the call to abilities.register().
Check that the registration sits after definitions.register(emberBlade) and before upgradedItem is created.
Ask for help with the registration code: Help me fix my local ability registration.
Activate behavior from the distributed item
Server B now has sharedItem from Hazelcast. It also has the locally resolved sharedDefinition and calculated sharedStats.
Those local values provide everything the registry needs to activate Ember Burst. The encoded item payload stays focused on identity and upgrades.
- In Main.kt, find the UUID check inside the try block.
- Add the ability activation directly below that check by copying this code:
val abilityId = checkNotNull(sharedDefinition.abilityId)
val abilityResult = abilities.activate(
abilityId,
AbilityContext("Alex", sharedItem, sharedStats)
)
println("Ability: $abilityResult")
How Does Activation Rebuild Gameplay?
- The abilityId comes from the local definition resolved through sharedItem.definitionId.
- The AbilityContext carries Alex's name into the behavior.
- The same context carries the distributed item instance and its final statistics.
- The local registry activates the matching lambda and returns the console message.
- Save Main.kt.
Before you run the complete demonstration, what damage and strength values should Alex's ability receive from the rebuilt item? Make your prediction now.
- Verify the complete distributed item flow by running this command:
./gradlew run
The ./gradlew run command rebuilds the project. It runs the complete console demonstration.
You will see Ability: Alex releases Ember Burst with 90 damage and 20 strength. before both Hazelcast members shut down.
You have completed the full architecture. Another server member now rebuilds the distributed item and activates its locally registered behavior.
Ability Output Missing?
Confirm the activation block sits inside try and before finally. Code placed after the shutdown block cannot use the completed distributed flow.
Check that the registered ID and the Ember Blade's abilityId both use ember_burst.
Ask for help with the final activation: Help me trace my distributed ability flow.
✔️ Awesome, I've got everything!
Excellent. Your complete demonstration now shares item state through Hazelcast and activates behavior from the local registry.
ⓧ I'd like to double check the full code
Your complete Main.kt file should now match this reference.
package dev.nextwork.items
import com.hazelcast.config.Config
import com.hazelcast.core.Hazelcast
import net.minestom.server.item.Material
import java.util.UUID
fun main() {
val definitions = ItemRegistry()
val abilities = AbilityRegistry()
val emberBlade = ItemDefinition(
id = "EMBER_BLADE",
material = Material.DIAMOND_SWORD,
displayName = "Ember Blade",
rarity = ItemRarity.RARE,
baseStats = mapOf(
ItemStat.DAMAGE to 75,
ItemStat.STRENGTH to 20
),
abilityId = "ember_burst"
)
definitions.register(emberBlade)
abilities.register(
"ember_burst",
ItemAbility { context ->
val damage = context.stats.getOrDefault(ItemStat.DAMAGE, 0)
val strength = context.stats.getOrDefault(ItemStat.STRENGTH, 0)
"${context.actor} releases Ember Burst with $damage damage and $strength strength."
}
)
val upgradedItem = ItemInstance(
uniqueId = UUID.randomUUID(),
definitionId = emberBlade.id,
upgrades = mapOf(ItemStat.DAMAGE to 15)
)
val initialStats = StatCalculator.calculate(emberBlade, upgradedItem)
val renderedItem = ItemRenderer.render(emberBlade, upgradedItem)
println("Rendered item: ${emberBlade.displayName}")
println("Material: ${renderedItem.material()}")
println("Final stats: $initialStats")
println("Embedded item id: ${ItemRenderer.readInstanceId(renderedItem)}")
check(ItemRenderer.readInstanceId(renderedItem) == upgradedItem.uniqueId)
val localServerA = LocalItemRepository()
val localServerB = LocalItemRepository()
localServerA.save(upgradedItem)
val missingItem = localServerB.find(upgradedItem.uniqueId)
println("Local repository on server B: ${missingItem?.definitionId ?: "missing"}")
check(missingItem == null)
val config = Config().setClusterName("nextwork-skyblock-items")
val memberA = Hazelcast.newHazelcastInstance(config)
val memberB = Hazelcast.newHazelcastInstance(config)
try {
val distributedServerA = HazelcastItemRepository(
memberA.getMap<String, String>("skyblock-item-instances")
)
val distributedServerB = HazelcastItemRepository(
memberB.getMap<String, String>("skyblock-item-instances")
)
distributedServerA.save(upgradedItem)
val sharedItem = checkNotNull(distributedServerB.find(upgradedItem.uniqueId))
val sharedDefinition = definitions[sharedItem.definitionId]
val sharedStats = StatCalculator.calculate(sharedDefinition, sharedItem)
val rebuiltItem = ItemRenderer.render(sharedDefinition, sharedItem)
println("Hazelcast repository on server B: ${sharedItem.definitionId}")
println(
"Rebuilt item id matches: " +
(ItemRenderer.readInstanceId(rebuiltItem) == upgradedItem.uniqueId)
)
check(ItemRenderer.readInstanceId(rebuiltItem) == upgradedItem.uniqueId)
val abilityId = checkNotNull(sharedDefinition.abilityId)
val abilityResult = abilities.activate(
abilityId,
AbilityContext("Alex", sharedItem, sharedStats)
)
println("Ability: $abilityResult")
} finally {
memberB.shutdown()
memberA.shutdown()
}
}
- Compare the local registries, distributed rebuild, ability activation, and shutdown order against your file.
Secret mission
Protect Concurrent Upgrades with an IMap Lock
Protect the Ember Blade's shared upgrade state with a per-key IMap lock. Run one update from each member to prove both increments survive.
Clean Up Your Resources
Clean Up Your Resources
The finally block shuts down both local Hazelcast members after the demonstration. Your remaining choices are to keep the distributed-skyblock-items folder, pause your work, or delete the folder at no ongoing cost.
Resources you used:
- Local distributed-skyblock-items project folder containing the complete item engine at no ongoing cost.
Keep everything running
No action is needed. Choose this option if you want to rerun the item engine or extend its architecture later.
- Keep the distributed-skyblock-items folder in its current location.
- Preserve the source files as a reference for the complete item engine.
Pause - I'll come back to this later
There are no running Hazelcast members to pause after the application exits.
- Leave the distributed-skyblock-items folder untouched.
- Return to the same folder when you are ready to continue.
Delete - I don't want to use this again
Deleting the folder is permanent. The command below removes every source file plus the build configuration.
- Return to the terminal from your final project run.
- Remove the local project folder by running these commands:
cd ..
rm -rf distributed-skyblock-items
The first command moves the terminal to the folder above your project. The second command permanently removes distributed-skyblock-items.
- Check that the project folder is gone by running this command:
ls
The ls command lists what remains in the parent folder. You should no longer see distributed-skyblock-items in the output.
Still See the Project Folder?
Your terminal may have started outside the project folder. Return to the terminal from your final project run before repeating the cleanup commands.
Need help checking the location safely? Help me locate and remove the distributed-skyblock-items folder on Linux.
Nice Work!
Nice Work!
You did it! Your distributed Kotlin item engine rebuilds upgraded items on another Hazelcast member.
You've learned how to:
- Separate reusable item definitions from uniquely identified instance state.
- Calculate final statistics from base values plus per-instance upgrades. Render those values into immutable Minestom ItemStacks with lore plus a hidden UUID tag.
- Share encoded item state through a Hazelcast IMap. Rebuild the item on a second member. Activate its behavior through a local ability registry.
- Secret Mission: Protect two concurrent upgrades with a per-key IMap lock. Preserve the full 27-point damage upgrade.
Ready to quiz yourself?