Back-end7 min read
Skeletons pissed me off... So I built a plugin
Building UniversalDespawnTimer, a small Bukkit/Spigot/Paper plugin that gives every dropped item its own despawn clock, and keeps it honest across chunk unloads, merges and restarts.
Post Facts
- Published
- Topic
- Back-end
- Reading time
- 7 min
- Stack
- Java, Bukkit API, Gradle, MockBukkit

In vanilla Minecraft, a dropped item lives for 6,000 ticks: five minutes of loaded time, and then it's gone. That's great for base game but for LifeSteal that can cause a series of unfortunate events to occur where you get ganged on by 10 million skeletons, baby zombies and creepers all at once and end up losing 5 of your 10 hearts.
Ever play this little game called Rust? No, you havent? You have a life? damn... Wish I did. But I digress Rust has this plugin system called Oxide and there was one oxide plugin that I was recalling that showed me a very interesting solutions to a similar problem I was having when I was hosting rust servers for my friends.
Universal Despawn Timer is my solution to this one very specific issue inspired by GatherMultiplier from Rust.
What it does
The whole plugin is one config file. There's a default rule for every item, optional overrides per material, and a dedicated rule for hearts that beats both:
defaults:
despawn-time: default # leave vanilla behaviour alone
prevent-mob-pickup: false
lifestealz-hearts:
enabled: true
despawn-time: 15m
prevent-mob-pickup: true
material-overrides:
DIAMOND:
despawn-time: 10m
prevent-mob-pickup: true
COBBLESTONE:
despawn-time: 2m
check-interval-ticks: 20A duration can be ticks (6000t), seconds, minutes, hours or days, or one of two keywords: default keeps vanilla behaviour, never keeps the item around for good. Mob pickup blocking only ever applies to non-player entities, so players can still grab everything. /despawntimer reload (or /udt reload) applies changes without a restart, and /udt status shows whether the LifeStealZ hook is connected.
The config is the easy part. Most of the work went into making the timers right.
Why not just cancel the despawn event?
The obvious approach is to listen for Bukkit's ItemDespawnEvent and cancel it until the item has been around long enough. The trouble is that a cancelled despawn just hands the item another full vanilla lifetime, so you can only make items last longer, in five-minute steps, and a two-minute cobblestone rule is out of reach.
So instead implement your own clock. Every tracked item two numbers: how many ticks it has lived, and how many it's allowed to live for. The despawn event is still cancelled, but only as a guard. The plugin's own counter decides when the item actually goes:
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onItemDespawn(ItemDespawnEvent event) {
PersistentDataContainer data = event.getEntity().getPersistentDataContainer();
Long duration = data.get(durationTicksKey, PersistentDataType.LONG);
Long elapsed = currentElapsedTicks(event.getEntity());
if (duration != null && elapsed != null && (duration < 0L || elapsed < duration)) {
// Bukkit grants another vanilla lifetime when this event is cancelled.
// Our tick counter continues independently and removes the item on time.
event.setCancelled(true);
}
}Because the state lives on the item, it survives everything that would wipe an in-memory map: plugin reloads, chunk unload and load cycles, and full server restarts.
A timer should count the time an item could actually be picked up, not the time the server happened to exist.
That's also why the timers only count ticks while the item's chunk is loaded. Unloaded chunks and offline servers pause the clock, which matches how vanilla treats item age (I thought this was a bug when I was testing at one point), and it means a heart dropped at the edge of the world is still there when someone finally walks back to it.
A queue, not a scan
The naive way to expire items is to loop over every tracked one each tick and check its age. That's fine with ten items and wasteful with ten thousand, which a busy server's mob farm can easily produce.
Instead, each tracked item gets a due tick when it's scheduled, and goes into a PriorityQueue ordered by that tick. Each server tick only peeks at the head of the queue:
private void expireDueItems() {
while (!expiryQueue.isEmpty() && expiryQueue.peek().dueTick() <= serverTick) {
Expiry expiry = expiryQueue.poll();
TrackedItem tracked = trackedItems.get(expiry.itemId());
if (tracked == null || tracked.dueTick() != expiry.dueTick()) {
continue; // stale entry: the item was picked up, merged or rescheduled
}
trackedItems.remove(expiry.itemId());
if (tracked.item().isValid()) {
tracked.item().remove();
}
}
}On a quiet tick that's one comparison. Expiry is still accurate to a single tick.
Pickups and merges happen constantly, and removing an arbitrary element from a Java priority queue is O(n). So the plugin doesn't. It drops the item from a HashMap and leaves its queue entry behind as a stale record, which the loop above skips because the IDs or due ticks no longer match. Every check-interval-ticks (one second by default), a maintenance pass writes the current elapsed time back to each item and rebuilds the queue from the map, which keeps the stale entries from piling up.
Merges are where timers get gamed
Vanilla merges nearby stacks of the same item into one entity. Without care, that's an exploit: drop a fresh diamond on top of an old one and the merged stack inherits the new, longer timer.
The merge handler keeps whichever timer is further along, so merging can never buy an item more time:
Long sourceElapsed = currentElapsedTicks(event.getEntity());
Long targetElapsed = currentElapsedTicks(event.getTarget());
if (sourceElapsed != null && (targetElapsed == null || sourceElapsed > targetElapsed)) {
target.set(elapsedTicksKey, PersistentDataType.LONG, sourceElapsed);
}A second handler at MONITOR priority, after any other plugin has had the chance to cancel the merge, untracks the absorbed entity and reschedules the target from its updated data.
But what about LifeStealz
LifeStealZ hearts are just custom items, and there can be several types of them. UniversalDespawnTimer declares LifeStealZ as a softdepend and never compiles against it. At runtime it looks the plugin up, reaches its API through reflection, and reads LifeStealZ's own items.yml to collect every item whose customItemType is heart. Any heart type a server adds is picked up automatically, with nothing to list in this plugin's config.
If LifeStealZ is missing, disabled, has no heart types, or has changed its API, the hook switches itself off, logs one clear message, and reports its state in /udt status. Everything else keeps working. If detection starts failing mid-session, the warning is logged once and then suppressed, so a broken integration can't flood the console every time an item drops.
Small things that mattered
- Config errors fail loudly. An invalid duration stops the plugin from enabling, or a reload from applying, with the reason in the log. An unknown material name is logged and skipped. Either way you find out at reload, not three hours later when items vanish.
- Durations round up to whole ticks, and
Math.multiplyExactcatches overflow.999999999999dis an error, not a negative number. - Old items aren't forgotten. Items that already existed when the plugin was installed, or sat in chunks that weren't loaded during a reload, get their rule applied the moment their chunk loads, starting from the age vanilla already gave them.
- Upgrades migrate. Version 1.0.0 stored timers differently. 1.1 recognises items written by the old version, restores their original lifetime flag, and moves them onto the tick-based clock, so nothing left over from the old release sits around forever.
Testing is the bane of my existance
Testing sucks! I don't want to start up a Minecraft server every time I need to test that the implementation works. So instead we use MockBukkit. The production code targets Java 17 so it runs on Minecraft 1.19+, while the tests use a Java 21 toolchain to run MockBukkit. That made it possible to test the scenarios that are horrific to do by hand: duration parsing, finding heart types in items.yml, and an integration suite that checks an item expires on exactly its configured tick, that the clock pauses while a chunk is unloaded, that a merged stack keeps the older timer, that a reload keeps elapsed time, and that disabling the plugin doesn't leave ordinary items immortal.
./gradlew test
./gradlew build # build/libs/UniversalDespawnTimer-1.1.1.jarTry it or Contribute
UniversalDespawnTimer is free software under the GPL v3. Grab the source, build the jar, drop it in plugins/, and you'll have a config file on first start. Issues and pull requests are welcome; the repo has a contributing guide if you want tohelp maintain it.
It's small but powerful. The players should never notice its there and if they do then there is a problem with the implementation.
Oh and also
Filed under Back-end
