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 20267 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
11 Nov 2026
Topic
Back-end
Reading time
7 min
Stack
Java, Bukkit API, Gradle, MockBukkit
A Minecraft server console full of ArcheryLuna's death messages, shot by Skeletons, blown up by Creepers and slain by Zombies

Contents

  1. 01What it does
  2. 02Why not just cancel the despawn event?
  3. 03A queue, not a scan
  4. 04Merges are where timers get gamed
  5. 05But what about LifeStealz
  6. 06Small things that mattered
  7. 07Testing is the bane of my existance
  8. 08Try it or Contribute

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.

01 /

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:

config.ymlYAML
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: 20

A 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.

02 /

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:

ItemTimerListener.javaJava
@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.

03 /

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:

ItemTimerListener.javaJava
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.

Note

check-interval-ticks only controls how often timer state is written back to items and the queue is cleaned. It doesn't make expiry any less precise: that's still checked every tick.

04 /

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:

ItemTimerListener.javaJava
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.

05 /

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.

06 /

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.multiplyExact catches overflow. 999999999999d is 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.

07 /

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.

Shell
./gradlew test
./gradlew build   # build/libs/UniversalDespawnTimer-1.1.1.jar

08 /

Try 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.

Universal Despawn Timer.

Oh and also

A post by ArcheryLuna on X: "There is a special place in hell for Baby Zombies and Skeletons in minecraft. I HATE THEM SO MUCH... Almost as much as markdown..."

Filed under Back-end

Next post
Maybe Kubernetes will save us

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