Johan Sebastian Luna
  1. Blog
  • Work
  • Blog
  • Games
  • About
  • Contact
Johan Sebastian Luna
  1. Blog
  2. /Back-end
  • Work
  • Blog
  • Games
  • About
  • Contact

Back-end11 Nov 20269 min read

Mace's are the worst thing to ever exist I cannot land a hit with them.

Building MaceManager for Eternal, a LifeSteal server: one plugin that caps how many maces exist, tracks who owns every one of them, and puts enchantment-based cooldowns on the swing. It also got me my hearts back.

Post Facts

Published
11 Nov 2026
Topic
Back-end
Reading time
9 min
Stack
Java, Paper API, SQLite, Gradle, MockBukkit
The Minecraft crafting recipe for a mace, a heavy core on top of a breeze rod

Contents

  1. 01What it does
  2. 02I'm gonna have to see your passport
  3. 03wya? im lonely :(
  4. 04If a user can do it... it will eventually be done
  5. 05Lag what's that???
  6. 06WHOA CALM YOUR HORSES ๐Ÿด๐Ÿด๐Ÿด
  7. 07The Thorns bug
  8. 08Testing, again
  9. 09I'm so squishy
  10. 10Try it or contribute

If you read the last post, you know how my time on Eternal was going. Skeletons, baby zombies, creepers, and me sitting at 5 of my 10 hearts after one very bad night.

Then the staff messaged me. Universal Despawn Timer had been running quietly on their server since I shipped it, and they had a new problem... the maces :(

The mace is the most broken item in the game when you play LifeSteal and even more so on this server since netherite gear is prohibited. Fall a long way, land a hit, and someone loses a heart in one swing. Eternal already had three separate plugins trying to keep it under control: one to limit how many maces could exist, one to track who had them, and one for cooldowns. The three didn't know about each other, the cooldowns weren't working the way they wanted, and every time one plugin updated, something in the other two broke.

What they asked for was simple to say: one plugin that does all three, and does them properly.

So I got to work, and that's how MaceManager was born.

01 /

What it does

MaceManager does three things:

  • Limits how many maces can exist on the server. Unlimited by default, or a hard cap like allowed-maces: 3.
  • Tracks every mace: who's holding it, whether it's in an ender chest or on a shelf, whether it's on the ground in some chunk nobody has loaded in a week.
  • Cools down mace swings, with the cooldown worked out from the enchantments on the mace and on the armor of the person swinging it.

Every option is off by default, so dropping the jar into a server changes nothing until an admin turns something on. Mace cooldowns in vanilla are just the attack-speed bar, and I didn't want a fresh install to quietly change PvP on anyone.

02 /

I'm gonna have to see your passport

The hard part of capping maces isn't counting them. It's knowing which mace is which.

A mace is a mace is a mace. Two of them in a chest look exactly the same to the game. So if you want to say "there are three maces on this server", you need a way to tell maces #1, #2 and #3 apart from a fourth one someone duplicated, or spawned with /give.

When a player crafts a mace, MaceManager hands them a new one with a UUID written into the item's persistent data:

MaceItems.javaJava
public TaggedMace createTracked() {
    UUID id = UUID.randomUUID();
    ItemStack stack = new ItemStack(Material.MACE);
    ItemMeta meta = stack.getItemMeta();
    meta.getPersistentDataContainer().set(idKey, PersistentDataType.STRING, id.toString());
    stack.setItemMeta(meta);
    return new TaggedMace(id, stack);
}

That ID is the mace's passport, and the plugin sorts every mace it sees into one of three buckets:

  • Tracked: it has a valid ID, and that ID is in the registry.
  • Untracked: an admin gave it out with /maces giveuntracked, so it doesn't count toward the cap.
  • Untagged: no ID, or a broken one. With a cap on, it's illegal, and it gets removed.

Duplicates are the fun one. A duped mace copies its ID along with everything else, so two items now claim to be the same mace. When the plugin finds the same ID in two places at once, one of them is a dupe, and it goes.

03 /

wya? im lonely :(

Knowing a mace exists isn't enough. Staff wanted to type /maces and see who has each one, and the answer has to survive a restart.

So every tracked mace has a record in a local SQLite database (maces.db, no database server, no passwords, the driver ships inside the jar). oThe record holds the mace's ID, who owns it, and where it is: carried by a player, dropped at certain chunk coordinates, sitting in a block at some position, held by an item frame entity, or tucked away in someone's ender chest, ect ect.

Looking at a mace in a chest doesn't make it yours. Picking it up does.

That rule was particularly troublsome to implement. Opening a chest that has a mace in it doesn't change who owns the mace. Taking it into your inventory or onto your cursor does. A shulker box carrying a mace keeps the mace's owner while it moves around.

Writes don't hit the disk on every change. The in-memory registry updates straight away, and changes are batched into one transaction on the next tick. If a save fails, the changes stay marked dirty and get retried, and shutdown drains anything still waiting before the plugin turns off.

Eternal's old plugins kept their data in a YAML file, so on first start MaceManager imports that maces.yml in a single transaction before any enforcement runs. If the import fails, the plugin refuses to start. I'd much rather the server owner sees an error in the console than have the plugin wake up with an empty registry and start deleting every "unknown" mace on the server.

04 /

If a user can do it... it will eventually be done

The storage rules were where most of the time went. Staff wanted maces to be kept in some places and not others: ender chests, shelves and item frames are fine, but chests, barrels, hoppers, bundles and shulker boxes are off by default.

config.ymlYAML
mace-storage:
  ender-chests: true
  shelves: true
  item-frames: true
  glow-item-frames: true
  chests: false
  barrels: false
  shulker-boxes: false
  bundles: false
  hoppers: false

Blocking a chest sounds easy until you count how many ways an item can get into one. Clicking, shift-clicking, dragging, number-key swaps, offhand swaps, hoppers pulling from the chest above, droppers, a minecart with a chest on it. Then there's nesting: a mace inside a bundle inside a shulker box inside a chest. Each layer has to be allowed in every layer around it.

Every one of those paths checks the rule for wherever the mace is going. Taking a mace out of blocked storage always works, so nobody gets their mace stuck. And if a mace ends up somewhere it shouldn't (a reload changed the rules, another plugin moved it without firing an event), cleanup gives it back to whoever is looking at the storage, or drops it on the floor nearby.

Note

Shelves only exist on newer versions of Minecraft, but the plugin still builds against Paper 1.21.4. Shelf support reads inventory names and block data properties instead of linking against the newer shelf classes, so the same jar works on both.

05 /

Lag what's that???

Events catch almost everything, but "almost" is the problem. Commands, other plugins and edge cases can move items without telling anyone. So every so often MaceManager runs a full audit: player inventories, storage blocks, item frames, items on the ground.

Doing all of that in a single tick would freeze the server. Instead the audit is split into small scan units with a time budget per tick (32 units or 2 ms by default), and the work is spread across as many ticks as it takes. Chunks that aren't loaded are left alone. Their maces stay in the registry and get checked when the chunk loads again.

If an audit can't find a mace, it doesn't delete the record right away. It runs one last check in the same tick to make sure the mace really is gone, because "I didn't see it" and "it doesn't exist" are very different things when the result is wiping out someone's hard earned mace.

06 /

WHOA CALM YOUR HORSES ๐Ÿด๐Ÿด๐Ÿด

This is the part Eternal actually came to me for. The formula is simple:

text
base seconds
+ sum of (mace enchantment level ร— seconds per level)
+ sum of (enchantment level ร— seconds per level) on each armor piece

Each enchantment gets its own rate in the config. Density III on a mace with density: 5 adds 15 seconds. Protection IV on a helmet and on a chestplate counts twice. Negative rates take time away. The total is clamped between a configured minimum and maximum, and if it comes out at zero or less, no cooldown starts at all.

The listener is split into two handlers on purpose. One runs at HIGH priority and cancels the hit if the player is still cooling down. The other runs at MONITOR, after every other plugin has had its say, and only starts a cooldown if the hit actually went through:

MaceCooldownListener.javaJava
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void startCooldownAfterHit(EntityDamageByEntityEvent event) {
    Player player = attacker(event);
    if (player == null || !plugin.settings().cooldownEnabled()) {
        return;
    }
    // ...work out the seconds from the mace and armor enchantments
    if (!plugin.cooldowns().startMace(player, seconds)) {
        return;
    }
    Notices.actionBar(player, plugin.settings().format("cooldown-applied", ...));
}

So a swing that a region protection plugin cancels, say, doesn't cost the player their cooldown. You can still use other weapons while the mace is cooling down, and there's an optional separate cooldown for wind charges too, because of course people were using those to set up mace hits.

07 /

The Thorns bug

Turns out Minecraft reports Thorns damage as the player wearing the armor attacking whoever hit them. So if you were holding a mace and got hit while wearing Thorns, the game told my plugin you had just swung your mace. Your cooldown restarted. Or, if you were already on cooldown, the plugin cancelled the Thorns damage, because as far as it knew you were swinging during your cooldown.

The fix is one check at the top of the attacker lookup:

MaceCooldownListener.javaJava
private Player attacker(EntityDamageByEntityEvent event) {
    if (plugin.settings().ignoreThorns() && event.getCause() == EntityDamageEvent.DamageCause.THORNS) {
        return null;
    }
    if (event.getCause() != EntityDamageEvent.DamageCause.ENTITY_ATTACK) {
        return null;
    }
    // ...and the player has to be holding a mace in their main hand
}

Thorns still does its damage. It just doesn't touch the cooldown, either way.

08 /

Testing, again

Same story as last time: I'm not starting a Minecraft server every time I change a line. MaceManager's test suite runs on MockBukkit and mocked server objects, and it covers the cooldown maths, every storage path I could think of, nested bundles and shulkers, shelves, SQLite upgrades and crash recovery, and the maces.yml import. The suite passes against both the Paper 1.21.4 and 1.21.11 APIs.

Shell
./gradlew test build   # build/libs/MaceManager-1.1.0.jar

Mocks only go so far, though. Real inventory transactions and other plugins on a live server are their own thing, and that's what Eternal was for.

09 /

I'm so squishy

Eternal paid me in the only currency that matters on a LifeSteal server.

MaceManager went live, the three old plugins came out, and I got my 3 hearts back (all five in total because I had gotten 2 for making Universal Upload Timer). Ten hearts again, like nothing ever happened. Then I fixed the Thorns bug and they threw in two more for it.

I'm now sitting at 12 hearts, which is the most I've ever had on that server, and every single one of them came from writing Java instead of playing the game and you know what im fine with that I cant PVP to save my life.

10 /

Try it or contribute

MaceManager is free software under GPL-3.0. It needs Java 21 and a Paper server (it should work on Bukkit and Spigot too, but I haven't tested those live). Build the jar, drop it in plugins/, edit the config it creates, and run /maces reload. If you already have maces on your world, back it up before you turn on a cap, because maces without an ID will be treated as illegal.

Issues and pull requests are welcome, and the repo has a contributing guide.

MaceManager on GitHub.

Filed under Back-end

Next post
Skeletons pissed me off... So I built a plugin

Get in touch

Got something worth building?

me@archery-luna.com
Back to top

Index

  • Home
  • Blog
  • Games
  • Style guide

Elsewhere

  • GitHub
  • LinkedIn
  • CV

Based in

London, UK

Open to interesting projects

Archery Luna.

ยฉ 2026 Johan Sebastian Luna

Designed and built by me