Patterns: cooldowns, toggles and player state
The small patterns behind almost every plugin feature: cooldowns, on/off toggles and temporary state.
Almost every plugin feature needs a little memory: "when can this player use the ability again?", "is this player in build mode?", "is this player still playing?". On this page you will build three small tools for exactly that: a reusable cooldown class, an on/off toggle and a state tracker, and learn to clean them up so your plugin never leaks memory.
Why plugins need a memory for players
Your event handlers and commands run for a moment and finish. Nothing in a handler remembers what happened the last time it ran. So when a player types /dash twice in a row and you want the second one refused, something has to keep a note between the two commands: "Steve dashed 3 seconds ago".
The place for that note is a field in one of your classes: a variable that belongs to an object and lives as long as the object does. Three kinds of notes come up again and again, and each has a Java collection that fits it perfectly:
| Question | What to remember | Tool |
|---|---|---|
| "Can Steve use this yet?" | The moment when it becomes ready, for each player | A Map<UUID, Long> |
| "Is Steve in build mode?" | Just yes or no for each player | A Set<UUID> |
| "What is Steve doing right now?" | One of a few fixed states for each player | A Map<UUID, State> with an enum |
If Map, Set and UUID are new to you, skim Lists, sets and maps and Time, UUIDs and randomness first. The short version: a map is a lookup table (a key leads to a value), a set is a bag where each thing is either in or out, and a UUID is the unique id every player has.
Cooldowns: remember when it is ready
The trick behind every cooldown is to not count down at all. Instead, store the moment when the player may act again, and compare it with the clock when they try. When the ability is used at second 0 with an 8-second cooldown, you store "ready at second 8". Any attempt before that is refused, and the number of seconds left is simply the ready time minus now.
Here is the idea as a plain Java program with a pretend clock, so you can run it and watch the logic without a server. The "now" is passed in as a number of milliseconds:
1Map<String, Long> readyAt = new HashMap<>();2 3boolean tryUse(String player, long now, long cooldownMillis) {4 Long ready = readyAt.get(player);5 if (ready != null && now < ready) {6 IO.println(player + " must wait " + (ready - now) + " more milliseconds.");7 return false;8 }9 readyAt.put(player, now + cooldownMillis);10 IO.println(player + " used the ability.");11 return true;12}13 14void main() {15 long cooldown = 5000;16 tryUse("Steve", 0, cooldown);17 tryUse("Steve", 2000, cooldown);18 tryUse("Alex", 2000, cooldown);19 tryUse("Steve", 5000, cooldown);20}- The lookup table: the key is the player, the value is the time when they may act again. Real plugins use the player's
UUIDas the key. - The capital-L
Longis the "boxed" number that can also benull.getreturnsnullfor a player who never used the ability. - The player is blocked only if they have a ready time and it is still in the future. The
nullcheck must come first, or Java crashes comparing nothing. - Success: remember when they can act next.
putreplaces the old value if there was one. - Two seconds later. Steve is refused with 3000 ms left, while Alex, who has no note yet, can go.
Why UUID and not the player
Notice the map keys are UUIDs, not Player objects and not names. A Player object stands for one login session. If the player leaves and comes back, you get a different object, and a map that holds the old one keeps it in memory for nothing. Names can change. A UUID belongs to the player for good: player.getUniqueId() gives it to you.
Why not a repeating task that counts down?
You could start a task that counts down every second for every player, as the scheduler page does for a countdown. That works, but you would run code every second for every player even when nobody is on cooldown, and each timer is one more thing to cancel when a player leaves. A stored timestamp costs nothing while it sits there and is checked only when the player actually tries something. For cooldowns, the timestamp is almost always the better tool.
A reusable Cooldowns class
You will want cooldowns in many places: a dash, a heal command, a kit. Writing the map logic each time invites mistakes, so put it in one small class and reuse it. Each ability gets its own Cooldowns object, and you ask it three questions: how long is left, start one, clear one.
Where this file livescooldowns-demosrcmainjavacomexamplecooldownsdemoCooldowns.java
The package com.example.cooldownsdemo is the folder path com/example/cooldownsdemo inside src/main/java: every dot in the package name is one folder. IntelliJ creates these folders for you when you make a new package.
- cooldowns-demo/
- src/main/
- java/com/example/cooldownsdemo/Package com.example.cooldownsdemo
- BuildMode.javaHelper class
- BuildModeCommand.javaCommand (BasicCommand)
- BuildModeListener.javaListener: reacts to events
- Cooldowns.javayou are hereHelper class
- CooldownsDemoPlugin.javaMain class: Paper starts here (named as main in plugin.yml)
- DashAbility.javaHelper class
- DashCommand.javaCommand (BasicCommand)
- DashFeatherListener.javaListener: reacts to events
- QuitCleanupListener.javaListener: reacts to events
- TimeFormat.javaHelper class
- resources/Files copied into the jar as they are
- plugin.ymlTells Paper the plugin's name, version and main class
- java/com/example/cooldownsdemo/Package com.example.cooldownsdemo
- build.gradle.ktsThe build recipe: Paper 26.3 API, Java 25, how the jar is made
- gradle.propertiesVersion numbers used by the build
- gradlew.batRuns Gradle on Windows without installing it
- settings.gradle.ktsThe project's name
- src/main/
Gray files come with the project template; you rarely edit them. Open the whole project in the Compile Lab, or download it from the project page.
1package com.example.cooldownsdemo;2 3import java.time.Duration;4import java.util.HashMap;5import java.util.Map;6import java.util.UUID;7 8public final class Cooldowns {9 10 private final Map<UUID, Long> readyAt = new HashMap<>();11 12 public long remainingMillis(UUID id) {13 Long ready = readyAt.get(id);14 if (ready == null) {15 return 0L;16 }17 long remaining = ready - System.currentTimeMillis();18 if (remaining <= 0L) {19 readyAt.remove(id);20 return 0L;21 }22 return remaining;23 }24 25 public boolean isReady(UUID id) {26 return remainingMillis(id) == 0L;27 }28 29 public void start(UUID id, Duration duration) {30 readyAt.put(id, System.currentTimeMillis() + duration.toMillis());31 }32 33 public void clear(UUID id) {34 readyAt.remove(id);35 }36}- The note-taking part. It is
private, so only this class touches the map and the rules stay in one place. - Answers "how many milliseconds are left?" A result of
0means "ready now". - This player never started a cooldown, so there is nothing to wait for.
- The real clock: milliseconds since 1 January 1970. Subtracting it from the ready time gives the time left.
- The cooldown is over, so the old note is thrown away. Cleaning up as you notice an expired entry keeps the map from filling with stale notes.
- A friendlier way to ask the same question when you do not need the number.
- A
Durationlets callers writeDuration.ofSeconds(8)instead of a bare number whose unit nobody can remember. - Forgets a player completely. You will call it when a player leaves.
Showing the time left nicely
"Wait 8000 milliseconds" is not friendly, and "Wait 0.99 seconds" looks odd. Players want "8s", "1m 30s" or "1h 2m 5s". This small function turns milliseconds into that. Run it and look at the output, especially the first line: 400 ms shows as 1s, because rounding up means the player never sees "0s" while still blocked.
1String format(long millis) {2 long totalSeconds = (millis + 999) / 1000;3 long hours = totalSeconds / 3600;4 long minutes = totalSeconds % 3600 / 60;5 long seconds = totalSeconds % 60;6 7 StringBuilder text = new StringBuilder();8 if (hours > 0) {9 text.append(hours).append("h ");10 }11 if (minutes > 0) {12 text.append(minutes).append("m ");13 }14 if (seconds > 0 || text.isEmpty()) {15 text.append(seconds).append("s");16 }17 return text.toString().strip();18}19 20void main() {21 long[] waits = {400, 1000, 4200, 60_000, 90_000, 3_725_000};22 for (long wait : waits) {23 IO.println(wait + " ms is shown as " + format(wait));24 }25}- Whole-number division rounds down, so adding 999 first makes it round up. 400 ms becomes 1 second, and 4200 ms becomes 5.
- The
%(remainder) operator removes the whole hours, then dividing by 60 gives the minutes that are left. - Builds the text piece by piece without creating a new
Stringfor every addition. - Add the seconds if there are any, or if nothing else was added, so zero shows "0s" and not an empty string.
- Removes the space left at the end when the last part was minutes or hours.
The plugin uses the same function, as a static method in a class called TimeFormat. Its body is identical, so it is not repeated here; you can see it in the downloadable project.
Example: a /dash ability
Now put the pieces together. The ability lives in its own class, DashAbility, so that more than one thing can trigger it: a command and a right-click with a feather. It owns the cooldown rules, and the command and listener just call it.
Where this file livescooldowns-demosrcmainjavacomexamplecooldownsdemoDashAbility.java
The package com.example.cooldownsdemo is the folder path com/example/cooldownsdemo inside src/main/java: every dot in the package name is one folder. IntelliJ creates these folders for you when you make a new package.
- cooldowns-demo/
- src/main/
- java/com/example/cooldownsdemo/Package com.example.cooldownsdemo
- BuildMode.javaHelper class
- BuildModeCommand.javaCommand (BasicCommand)
- BuildModeListener.javaListener: reacts to events
- Cooldowns.javaHelper class
- CooldownsDemoPlugin.javaMain class: Paper starts here (named as main in plugin.yml)
- DashAbility.javayou are hereHelper class
- DashCommand.javaCommand (BasicCommand)
- DashFeatherListener.javaListener: reacts to events
- QuitCleanupListener.javaListener: reacts to events
- TimeFormat.javaHelper class
- resources/Files copied into the jar as they are
- plugin.ymlTells Paper the plugin's name, version and main class
- java/com/example/cooldownsdemo/Package com.example.cooldownsdemo
- build.gradle.ktsThe build recipe: Paper 26.3 API, Java 25, how the jar is made
- gradle.propertiesVersion numbers used by the build
- gradlew.batRuns Gradle on Windows without installing it
- settings.gradle.ktsThe project's name
- src/main/
Gray files come with the project template; you rarely edit them. Open the whole project in the Compile Lab, or download it from the project page.
22public boolean tryDash(Player player) {23 long remaining = cooldowns.remainingMillis(player.getUniqueId());24 if (remaining > 0L) {25 player.sendRichMessage("<red>Dash is recharging: <white><time></white> left.",26 Placeholder.unparsed("time", TimeFormat.remaining(remaining)));27 return false;28 }29 30 Vector direction = player.getLocation().getDirection();31 player.setVelocity(direction.multiply(SPEED).setY(LIFT));32 player.playSound(player, Sound.ENTITY_BREEZE_WIND_BURST, 1.0f, 1.0f);33 34 cooldowns.start(player.getUniqueId(), COOLDOWN);35 player.setCooldown(Material.FEATHER, (int) (COOLDOWN.toMillis() / 50));36 return true;37}- Ask the cooldown helper how long is left for this player.
- Still waiting: explain why and stop. Always tell the player what is going on, or they will think the ability is broken.
- Turns the milliseconds into text such as "5s".
- A
Vectorof length 1 pointing where the player looks. - Stretches the direction to make it faster, forces a small upward push so the player hops instead of scraping the ground, and gives that velocity to the player.
- A whoosh that only this player hears.
- Only now, after the dash really happened, does the cooldown begin. Starting it before a check that might fail would punish players for nothing.
- Shows the gray recharge sweep on feathers in the hotbar. The next section explains the difference.
Where this file livescooldowns-demosrcmainjavacomexamplecooldownsdemoDashCommand.java
The package com.example.cooldownsdemo is the folder path com/example/cooldownsdemo inside src/main/java: every dot in the package name is one folder. IntelliJ creates these folders for you when you make a new package.
- cooldowns-demo/
- src/main/
- java/com/example/cooldownsdemo/Package com.example.cooldownsdemo
- BuildMode.javaHelper class
- BuildModeCommand.javaCommand (BasicCommand)
- BuildModeListener.javaListener: reacts to events
- Cooldowns.javaHelper class
- CooldownsDemoPlugin.javaMain class: Paper starts here (named as main in plugin.yml)
- DashAbility.javaHelper class
- DashCommand.javayou are hereCommand (BasicCommand)
- DashFeatherListener.javaListener: reacts to events
- QuitCleanupListener.javaListener: reacts to events
- TimeFormat.javaHelper class
- resources/Files copied into the jar as they are
- plugin.ymlTells Paper the plugin's name, version and main class
- java/com/example/cooldownsdemo/Package com.example.cooldownsdemo
- build.gradle.ktsThe build recipe: Paper 26.3 API, Java 25, how the jar is made
- gradle.propertiesVersion numbers used by the build
- gradlew.batRuns Gradle on Windows without installing it
- settings.gradle.ktsThe project's name
- src/main/
Gray files come with the project template; you rarely edit them. Open the whole project in the Compile Lab, or download it from the project page.
15@Override16public void execute(CommandSourceStack source, String[] args) {17 if (!(source.getExecutor() instanceof Player player)) {18 source.getSender().sendRichMessage("<red>Only players can dash.");19 return;20 }21 dash.tryDash(player);22}- Only a player can dash. The console has no body to move. The pattern is explained in Commands, part 1.
- All the real work is in
DashAbility; the command is only a doorway.
Cooldown visuals are not cooldown logic
Minecraft has its own cooldown display: the gray sweep that covers an ender pearl or a shield after use. Paper lets you trigger it with player.setCooldown(Material.FEATHER, ticks), where the time is in ticks, not milliseconds. The DashAbility above uses it, so right after a dash every feather in the player's hotbar shows the sweep.
That sweep is a picture. For items that already do something in vanilla, like pearls, Minecraft respects it by itself. For an item like a feather that does nothing on its own, the sweep only tells the player to wait; your code must still refuse a dash during the cooldown. That is why DashAbility keeps its own Cooldowns object as the real rule and uses setCooldown only as decoration. The best result uses both: the player sees the sweep and your logic enforces it.
This is the listener that lets a right-click with a feather start the same ability:
Where this file livescooldowns-demosrcmainjavacomexamplecooldownsdemoDashFeatherListener.java
The package com.example.cooldownsdemo is the folder path com/example/cooldownsdemo inside src/main/java: every dot in the package name is one folder. IntelliJ creates these folders for you when you make a new package.
- cooldowns-demo/
- src/main/
- java/com/example/cooldownsdemo/Package com.example.cooldownsdemo
- BuildMode.javaHelper class
- BuildModeCommand.javaCommand (BasicCommand)
- BuildModeListener.javaListener: reacts to events
- Cooldowns.javaHelper class
- CooldownsDemoPlugin.javaMain class: Paper starts here (named as main in plugin.yml)
- DashAbility.javaHelper class
- DashCommand.javaCommand (BasicCommand)
- DashFeatherListener.javayou are hereListener: reacts to events
- QuitCleanupListener.javaListener: reacts to events
- TimeFormat.javaHelper class
- resources/Files copied into the jar as they are
- plugin.ymlTells Paper the plugin's name, version and main class
- java/com/example/cooldownsdemo/Package com.example.cooldownsdemo
- build.gradle.ktsThe build recipe: Paper 26.3 API, Java 25, how the jar is made
- gradle.propertiesVersion numbers used by the build
- gradlew.batRuns Gradle on Windows without installing it
- settings.gradle.ktsThe project's name
- src/main/
Gray files come with the project template; you rarely edit them. Open the whole project in the Compile Lab, or download it from the project page.
19@EventHandler20public void onInteract(PlayerInteractEvent event) {21 if (event.getHand() != EquipmentSlot.HAND) {22 return;23 }24 if (event.getAction() != Action.RIGHT_CLICK_AIR && event.getAction() != Action.RIGHT_CLICK_BLOCK) {25 return;26 }27 if (event.getItem() == null || event.getItem().getType() != Material.FEATHER) {28 return;29 }30 Player player = event.getPlayer();31 if (!player.hasPermission("cooldownsdemo.dash")) {32 return;33 }34 event.setCancelled(true);35 dash.tryDash(player);36}- This event fires once for each hand. Without this check, one click would try to dash twice, and the second try would say "recharging".
- Only right-clicks count: in the air or on a block.
- The player may click with an empty hand, in which case there is no item at all. Check for
nullbefore asking for its type. - Players without the dash permission just use the feather normally.
- Stops the click from also doing whatever it would normally do, such as using a block.
Toggles: on and off with a Set
A toggle is a switch that a player flips: build mode, a particle trail, vanish. You only need to remember who has it on, which is exactly what a Set is for: a collection where a thing is either inside or not, with no duplicates.
Where this file livescooldowns-demosrcmainjavacomexamplecooldownsdemoBuildMode.java
The package com.example.cooldownsdemo is the folder path com/example/cooldownsdemo inside src/main/java: every dot in the package name is one folder. IntelliJ creates these folders for you when you make a new package.
- cooldowns-demo/
- src/main/
- java/com/example/cooldownsdemo/Package com.example.cooldownsdemo
- BuildMode.javayou are hereHelper class
- BuildModeCommand.javaCommand (BasicCommand)
- BuildModeListener.javaListener: reacts to events
- Cooldowns.javaHelper class
- CooldownsDemoPlugin.javaMain class: Paper starts here (named as main in plugin.yml)
- DashAbility.javaHelper class
- DashCommand.javaCommand (BasicCommand)
- DashFeatherListener.javaListener: reacts to events
- QuitCleanupListener.javaListener: reacts to events
- TimeFormat.javaHelper class
- resources/Files copied into the jar as they are
- plugin.ymlTells Paper the plugin's name, version and main class
- java/com/example/cooldownsdemo/Package com.example.cooldownsdemo
- build.gradle.ktsThe build recipe: Paper 26.3 API, Java 25, how the jar is made
- gradle.propertiesVersion numbers used by the build
- gradlew.batRuns Gradle on Windows without installing it
- settings.gradle.ktsThe project's name
- src/main/
Gray files come with the project template; you rarely edit them. Open the whole project in the Compile Lab, or download it from the project page.
1package com.example.cooldownsdemo;2 3import java.util.HashSet;4import java.util.Set;5import java.util.UUID;6 7public final class BuildMode {8 9 private final Set<UUID> active = new HashSet<>();10 11 public boolean toggle(UUID id) {12 if (active.remove(id)) {13 return false;14 }15 active.add(id);16 return true;17 }18 19 public boolean isActive(UUID id) {20 return active.contains(id);21 }22 23 public void remove(UUID id) {24 active.remove(id);25 }26}- The set of players who have build mode on. A
HashSetis fast at "is this in the set?". - Does two jobs at once: it removes the player if present and tells you with
trueorfalsewhether they were there. So "was on, now off" is a single line. - They were not in the set, so switching means turning it on.
- The question every listener asks: is this player's switch on?
Where this file livescooldowns-demosrcmainjavacomexamplecooldownsdemoBuildModeCommand.java
The package com.example.cooldownsdemo is the folder path com/example/cooldownsdemo inside src/main/java: every dot in the package name is one folder. IntelliJ creates these folders for you when you make a new package.
- cooldowns-demo/
- src/main/
- java/com/example/cooldownsdemo/Package com.example.cooldownsdemo
- BuildMode.javaHelper class
- BuildModeCommand.javayou are hereCommand (BasicCommand)
- BuildModeListener.javaListener: reacts to events
- Cooldowns.javaHelper class
- CooldownsDemoPlugin.javaMain class: Paper starts here (named as main in plugin.yml)
- DashAbility.javaHelper class
- DashCommand.javaCommand (BasicCommand)
- DashFeatherListener.javaListener: reacts to events
- QuitCleanupListener.javaListener: reacts to events
- TimeFormat.javaHelper class
- resources/Files copied into the jar as they are
- plugin.ymlTells Paper the plugin's name, version and main class
- java/com/example/cooldownsdemo/Package com.example.cooldownsdemo
- build.gradle.ktsThe build recipe: Paper 26.3 API, Java 25, how the jar is made
- gradle.propertiesVersion numbers used by the build
- gradlew.batRuns Gradle on Windows without installing it
- settings.gradle.ktsThe project's name
- src/main/
Gray files come with the project template; you rarely edit them. Open the whole project in the Compile Lab, or download it from the project page.
15@Override16public void execute(CommandSourceStack source, String[] args) {17 if (!(source.getExecutor() instanceof Player player)) {18 source.getSender().sendRichMessage("<red>Only players can build.");19 return;20 }21 boolean nowOn = buildMode.toggle(player.getUniqueId());22 if (nowOn) {23 player.sendRichMessage("<green>Build mode is on. Broken blocks no longer drop items.");24 } else {25 player.sendRichMessage("<yellow>Build mode is off.");26 }27}- Flip the switch and learn the new value. The result is what decides which message to show.
The switch itself does nothing until some other code reads it. Here a listener reads the set: in build mode, broken blocks leave no item drops, so builders do not flood the ground with dirt.
Where this file livescooldowns-demosrcmainjavacomexamplecooldownsdemoBuildModeListener.java
The package com.example.cooldownsdemo is the folder path com/example/cooldownsdemo inside src/main/java: every dot in the package name is one folder. IntelliJ creates these folders for you when you make a new package.
- cooldowns-demo/
- src/main/
- java/com/example/cooldownsdemo/Package com.example.cooldownsdemo
- BuildMode.javaHelper class
- BuildModeCommand.javaCommand (BasicCommand)
- BuildModeListener.javayou are hereListener: reacts to events
- Cooldowns.javaHelper class
- CooldownsDemoPlugin.javaMain class: Paper starts here (named as main in plugin.yml)
- DashAbility.javaHelper class
- DashCommand.javaCommand (BasicCommand)
- DashFeatherListener.javaListener: reacts to events
- QuitCleanupListener.javaListener: reacts to events
- TimeFormat.javaHelper class
- resources/Files copied into the jar as they are
- plugin.ymlTells Paper the plugin's name, version and main class
- java/com/example/cooldownsdemo/Package com.example.cooldownsdemo
- build.gradle.ktsThe build recipe: Paper 26.3 API, Java 25, how the jar is made
- gradle.propertiesVersion numbers used by the build
- gradlew.batRuns Gradle on Windows without installing it
- settings.gradle.ktsThe project's name
- src/main/
Gray files come with the project template; you rarely edit them. Open the whole project in the Compile Lab, or download it from the project page.
15@EventHandler16public void onBreak(BlockBreakEvent event) {17 if (buildMode.isActive(event.getPlayer().getUniqueId())) {18 event.setDropItems(false);19 }20}- Ask the toggle about this player.
- The block still breaks, but nothing drops. Different from
setCancelled(true), which would keep the block in place.
Build mode is off.
Clean up when players leave
Every note you keep is a little memory. A player who used /dash once and never returned still has an entry in your map. Multiply that by months of players and the map grows forever. This is called a memory leak: memory that nobody needs any more but that nothing ever frees.
For toggles it is worse: a player who logs out with build mode on and logs in tomorrow would still be in build mode, even though they never asked for it. The fix is a tiny listener that forgets the player on PlayerQuitEvent. If you forgot what that is, see Events and listeners.
Where this file livescooldowns-demosrcmainjavacomexamplecooldownsdemoQuitCleanupListener.java
The package com.example.cooldownsdemo is the folder path com/example/cooldownsdemo inside src/main/java: every dot in the package name is one folder. IntelliJ creates these folders for you when you make a new package.
- cooldowns-demo/
- src/main/
- java/com/example/cooldownsdemo/Package com.example.cooldownsdemo
- BuildMode.javaHelper class
- BuildModeCommand.javaCommand (BasicCommand)
- BuildModeListener.javaListener: reacts to events
- Cooldowns.javaHelper class
- CooldownsDemoPlugin.javaMain class: Paper starts here (named as main in plugin.yml)
- DashAbility.javaHelper class
- DashCommand.javaCommand (BasicCommand)
- DashFeatherListener.javaListener: reacts to events
- QuitCleanupListener.javayou are hereListener: reacts to events
- TimeFormat.javaHelper class
- resources/Files copied into the jar as they are
- plugin.ymlTells Paper the plugin's name, version and main class
- java/com/example/cooldownsdemo/Package com.example.cooldownsdemo
- build.gradle.ktsThe build recipe: Paper 26.3 API, Java 25, how the jar is made
- gradle.propertiesVersion numbers used by the build
- gradlew.batRuns Gradle on Windows without installing it
- settings.gradle.ktsThe project's name
- src/main/
Gray files come with the project template; you rarely edit them. Open the whole project in the Compile Lab, or download it from the project page.
18@EventHandler19public void onQuit(PlayerQuitEvent event) {20 UUID id = event.getPlayer().getUniqueId();21 dashCooldowns.clear(id);22 buildMode.remove(id);23}- Fires when a player leaves the server for any reason: quit, kick or lost connection.
- Forget their cooldown. The cooldown was also being cleared lazily when it expired, but a player who leaves in the middle of one would otherwise stay in the map.
- Switch build mode off for good.
One rule: every time you write map.put(...) or set.add(...) for a player, ask yourself "where does this entry get removed?" If you cannot answer, you have a leak.
States: what is the player doing?
Some things are not on or off but one of several. In a minigame, a player is in the lobby, playing, or spectating after being knocked out. Using three separate sets would let a player be in two at once by mistake. A cleaner idea is a state machine: each player has exactly one state, and your code changes it when something happens.
In Java, a fixed list of options is an enum. An enum can even carry rules. Here each state knows whether its players take damage, so no other code needs a list of "if state is LOBBY or SPECTATING":
Where this file livescooldowns-timers-statessrcmainjavacomexamplecooldownstimersstatesArenaState.java
The package com.example.cooldownstimersstates is the folder path com/example/cooldownstimersstates inside src/main/java: every dot in the package name is one folder. IntelliJ creates these folders for you when you make a new package.
- cooldowns-timers-states/
- src/main/
- java/com/example/cooldownstimersstates/Package com.example.cooldownstimersstates
- ArenaCommand.javaCommand (BasicCommand)
- ArenaListener.javaListener: reacts to events
- ArenaPlayers.javaHelper class
- ArenaState.javayou are hereEnum: a fixed list of choices
- StatesPlugin.javaMain class: Paper starts here (named as main in plugin.yml)
- resources/Files copied into the jar as they are
- plugin.ymlTells Paper the plugin's name, version and main class
- java/com/example/cooldownstimersstates/Package com.example.cooldownstimersstates
- build.gradle.ktsThe build recipe: Paper 26.3 API, Java 25, how the jar is made
- gradle.propertiesVersion numbers used by the build
- gradlew.batRuns Gradle on Windows without installing it
- settings.gradle.ktsThe project's name
- src/main/
Gray files come with the project template; you rarely edit them. Open the whole project in the Compile Lab, or download it from the project page.
1package com.example.cooldownstimersstates;2 3public enum ArenaState {4 LOBBY(false),5 PLAYING(true),6 SPECTATING(false);7 8 private final boolean takesDamage;9 10 ArenaState(boolean takesDamage) {11 this.takesDamage = takesDamage;12 }13 14 public boolean takesDamage() {15 return takesDamage;16 }17}- One of the allowed values. The
falsein brackets is the constructor argument: lobby players do not take damage. - Each constant carries its own copy of this answer.
- The enum's constructor. Java calls it once for each constant above. See Enums, records and modern Java.
Where this file livescooldowns-timers-statessrcmainjavacomexamplecooldownstimersstatesArenaPlayers.java
The package com.example.cooldownstimersstates is the folder path com/example/cooldownstimersstates inside src/main/java: every dot in the package name is one folder. IntelliJ creates these folders for you when you make a new package.
- cooldowns-timers-states/
- src/main/
- java/com/example/cooldownstimersstates/Package com.example.cooldownstimersstates
- ArenaCommand.javaCommand (BasicCommand)
- ArenaListener.javaListener: reacts to events
- ArenaPlayers.javayou are hereHelper class
- ArenaState.javaEnum: a fixed list of choices
- StatesPlugin.javaMain class: Paper starts here (named as main in plugin.yml)
- resources/Files copied into the jar as they are
- plugin.ymlTells Paper the plugin's name, version and main class
- java/com/example/cooldownstimersstates/Package com.example.cooldownstimersstates
- build.gradle.ktsThe build recipe: Paper 26.3 API, Java 25, how the jar is made
- gradle.propertiesVersion numbers used by the build
- gradlew.batRuns Gradle on Windows without installing it
- settings.gradle.ktsThe project's name
- src/main/
Gray files come with the project template; you rarely edit them. Open the whole project in the Compile Lab, or download it from the project page.
11public ArenaState get(UUID id) {12 return states.getOrDefault(id, ArenaState.LOBBY);13}- A player we have never heard of is in the lobby. This default means a brand-new player needs no setup.
The listener then reads the state to decide what to do:
Where this file livescooldowns-timers-statessrcmainjavacomexamplecooldownstimersstatesArenaListener.java
The package com.example.cooldownstimersstates is the folder path com/example/cooldownstimersstates inside src/main/java: every dot in the package name is one folder. IntelliJ creates these folders for you when you make a new package.
- cooldowns-timers-states/
- src/main/
- java/com/example/cooldownstimersstates/Package com.example.cooldownstimersstates
- ArenaCommand.javaCommand (BasicCommand)
- ArenaListener.javayou are hereListener: reacts to events
- ArenaPlayers.javaHelper class
- ArenaState.javaEnum: a fixed list of choices
- StatesPlugin.javaMain class: Paper starts here (named as main in plugin.yml)
- resources/Files copied into the jar as they are
- plugin.ymlTells Paper the plugin's name, version and main class
- java/com/example/cooldownstimersstates/Package com.example.cooldownstimersstates
- build.gradle.ktsThe build recipe: Paper 26.3 API, Java 25, how the jar is made
- gradle.propertiesVersion numbers used by the build
- gradlew.batRuns Gradle on Windows without installing it
- settings.gradle.ktsThe project's name
- src/main/
Gray files come with the project template; you rarely edit them. Open the whole project in the Compile Lab, or download it from the project page.
1package com.example.cooldownstimersstates;2 3import org.bukkit.entity.Player;4import org.bukkit.event.EventHandler;5import org.bukkit.event.Listener;6import org.bukkit.event.entity.EntityDamageEvent;7import org.bukkit.event.entity.PlayerDeathEvent;8import org.bukkit.event.player.PlayerQuitEvent;9 10final class ArenaListener implements Listener {11 12 private final ArenaPlayers players;13 14 ArenaListener(ArenaPlayers players) {15 this.players = players;16 }17 18 @EventHandler19 public void onDamage(EntityDamageEvent event) {20 if (!(event.getEntity() instanceof Player player)) {21 return;22 }23 if (!players.get(player.getUniqueId()).takesDamage()) {24 event.setCancelled(true);25 }26 }27 28 @EventHandler29 public void onDeath(PlayerDeathEvent event) {30 Player player = event.getPlayer();31 if (players.get(player.getUniqueId()) == ArenaState.PLAYING) {32 players.set(player.getUniqueId(), ArenaState.SPECTATING);33 player.sendRichMessage("<gray>You are out. You are now spectating.");34 }35 }36 37 @EventHandler38 public void onQuit(PlayerQuitEvent event) {39 players.remove(event.getPlayer().getUniqueId());40 }41}- Damage can hit any entity. Do nothing unless it is a player, and name that player in the same line.
- Ask the state: do you take damage? If not, cancel the event, so lobby and spectating players are safe.
- Only a player who was playing is moved on to spectating when they die.
- The state change. All other code now sees the new state with no further work.
- The same cleanup as before: forget the player when they leave.
You are out. You are now spectating.
Try it: join your test server, type /arena playing, then let a zombie hit you, and then type /arena lobby and notice that damage stops. You can download the state example as a Gradle project.
When memory is not enough
Everything on this page lives in memory, so it disappears when the server restarts. For a dash cooldown that is fine. If a cooldown must survive a restart, such as "one daily reward per player", store the ready time in a file, a database or on the player with PersistentDataContainer, and see Saving player data. The same idea works: a long ready time.
The whole demo plugin
- cooldowns-demo/
- src/
- main/
- java/
- com/example/cooldownsdemo/
- CooldownsDemoPlugin.javaCreates the shared objects and registers commands and listeners
- Cooldowns.javaThe reusable cooldown class
- TimeFormat.javaTurns milliseconds into "1m 30s"
- DashAbility.javaThe ability rules: cooldown check, velocity, sound
- DashCommand.java/dash
- DashFeatherListener.javaRight-click a feather to dash
- BuildMode.javaThe toggle, a set of UUIDs
- BuildModeCommand.java/buildmode
- BuildModeListener.javaStops drops while in build mode
- QuitCleanupListener.javaForgets players when they leave
- com/example/cooldownsdemo/
- resources/
- plugin.ymlThe two permissions
- java/
- main/
- src/
Where this file livescooldowns-demosrcmainjavacomexamplecooldownsdemoCooldownsDemoPlugin.java
The package com.example.cooldownsdemo is the folder path com/example/cooldownsdemo inside src/main/java: every dot in the package name is one folder. IntelliJ creates these folders for you when you make a new package.
- cooldowns-demo/
- src/main/
- java/com/example/cooldownsdemo/Package com.example.cooldownsdemo
- BuildMode.javaHelper class
- BuildModeCommand.javaCommand (BasicCommand)
- BuildModeListener.javaListener: reacts to events
- Cooldowns.javaHelper class
- CooldownsDemoPlugin.javayou are hereMain class: Paper starts here (named as main in plugin.yml)
- DashAbility.javaHelper class
- DashCommand.javaCommand (BasicCommand)
- DashFeatherListener.javaListener: reacts to events
- QuitCleanupListener.javaListener: reacts to events
- TimeFormat.javaHelper class
- resources/Files copied into the jar as they are
- plugin.ymlTells Paper the plugin's name, version and main class
- java/com/example/cooldownsdemo/Package com.example.cooldownsdemo
- build.gradle.ktsThe build recipe: Paper 26.3 API, Java 25, how the jar is made
- gradle.propertiesVersion numbers used by the build
- gradlew.batRuns Gradle on Windows without installing it
- settings.gradle.ktsThe project's name
- src/main/
Gray files come with the project template; you rarely edit them. Open the whole project in the Compile Lab, or download it from the project page.
8@Override9public void onEnable() {10 Cooldowns dashCooldowns = new Cooldowns();11 DashAbility dash = new DashAbility(dashCooldowns);12 BuildMode buildMode = new BuildMode();13 14 getServer().getPluginManager().registerEvents(new DashFeatherListener(dash), this);15 getServer().getPluginManager().registerEvents(new BuildModeListener(buildMode), this);16 getServer().getPluginManager().registerEvents(new QuitCleanupListener(dashCooldowns, buildMode), this);17 18 getLifecycleManager().registerEventHandler(LifecycleEvents.COMMANDS, event -> {19 event.registrar().register("dash", "Dash forward (also: right-click a feather)", new DashCommand(dash));20 event.registrar().register("buildmode", "Turn build mode on or off", new BuildModeCommand(buildMode));21 });22}- One object created once. Everything that needs the dash cooldown gets the same one.
- The ability is built from the cooldown object. This hand-over is called passing a dependency.
- The cleanup listener receives the two things it must clean.
Download the cooldowns demo as a Gradle project. As an operator, try /dash twice in a row, then /buildmode and break a few blocks.
Mistakes to avoid
- Storing
Playerobjects in a map or set that lives a long time. Use theUUID. - Never removing entries. Remove players on quit, and remove expired cooldowns when you notice them.
- Starting the cooldown before the action can fail. Check that the player is allowed, do the thing, then start the cooldown.
- Mixing milliseconds and ticks. The clock gives milliseconds; Minecraft's item cooldown and the scheduler use ticks (50 ms each).
- Using a name as a key. A player can change their name. The
UUIDnever changes. - Forgetting that
PlayerInteractEventfires once per hand. CheckgetHand(). - Showing no message. A command that silently does nothing looks broken. Always say what is happening and how long to wait.
A /boost command with a 30-second cooldown
Write /boost. It gives the player Speed II for 10 seconds, then locks the command for 30 seconds. If the player tries it early, tell them how many seconds are left. Do not forget to clean up when they quit.
Hint 1
Copy the Cooldowns class from this page. player.addPotionEffect(new PotionEffect(PotionEffectType.SPEED, ticks, 1)) gives an effect; level II is amplifier 1, because amplifiers start at 0.
Hint 2
Ten seconds is 200 ticks. For the message, divide the remaining milliseconds by 1000, rounding up with + 999, just like TimeFormat.
Show the solution
The command checks the cooldown first, and only starts it after the effect is given. A small listener (shown in the downloadable solution) clears the entry on quit.
Where this file livescooldowns-timers-exercisesrcmainjavacomexamplecooldownstimersexerciseBoostCommand.java
The package com.example.cooldownstimersexercise is the folder path com/example/cooldownstimersexercise inside src/main/java: every dot in the package name is one folder. IntelliJ creates these folders for you when you make a new package.
- cooldowns-timers-exercise/
- src/main/
- java/com/example/cooldownstimersexercise/Package com.example.cooldownstimersexercise
- BoostCommand.javayou are hereCommand (BasicCommand)
- Cooldowns.javaHelper class
- ExercisePlugin.javaMain class: Paper starts here (named as main in plugin.yml)
- QuitListener.javaListener: reacts to events
- resources/Files copied into the jar as they are
- plugin.ymlTells Paper the plugin's name, version and main class
- java/com/example/cooldownstimersexercise/Package com.example.cooldownstimersexercise
- build.gradle.ktsThe build recipe: Paper 26.3 API, Java 25, how the jar is made
- gradle.propertiesVersion numbers used by the build
- gradlew.batRuns Gradle on Windows without installing it
- settings.gradle.ktsThe project's name
- src/main/
Gray files come with the project template; you rarely edit them. Open the whole project in the Compile Lab, or download it from the project page.
22@Override23public void execute(CommandSourceStack source, String[] args) {24 if (!(source.getExecutor() instanceof Player player)) {25 source.getSender().sendRichMessage("<red>Only players can boost.");26 return;27 }28 long remainingSeconds = (cooldowns.remainingMillis(player.getUniqueId()) + 999) / 1000;29 if (remainingSeconds > 0) {30 player.sendRichMessage("<red>Boost is recharging: <white><seconds>s</white> left.",31 Placeholder.unparsed("seconds", String.valueOf(remainingSeconds)));32 return;33 }34 player.addPotionEffect(new PotionEffect(PotionEffectType.SPEED, BOOST_TICKS, 1));35 cooldowns.start(player.getUniqueId(), COOLDOWN);36 player.sendRichMessage("<green>Boost! You run faster for 10 seconds.");37}- Rounds the milliseconds up to whole seconds, so the player never reads "0s left" while still blocked.
- Speed, for 200 ticks, at amplifier 1, which is Speed II.
- The cooldown starts only after the effect was given.
Find the memory leak
This code gives each player a free reward once. What goes wrong over time, and which event would you listen to in order to fix it?
1private final Set<Player> rewarded = new HashSet<>();2 3public void giveReward(Player player) {4 if (rewarded.add(player)) {5 player.getInventory().addItem(ItemStack.of(Material.DIAMOND));6 }7}Hint
Think about what a Player object is, and what happens to the set when thousands of different players join over a few weeks.
Show the solution
There are two problems. The set holds Player objects, so every player who ever got a reward stays in memory, and when the same person logs in again they are a new Player object that the set may not recognize as the same entry, so they could get the reward a second time. Store the UUID instead. If the reward is "once per session", also remove the entry in a PlayerQuitEvent handler. If it is "once ever", the set must be saved to disk, since memory is lost on restart.
Recap
- Plugins remember things per player in fields: a
Mapfor values, aSetfor yes/no, and an enum-valued map for states. - Key everything by
UUID, never byPlayeror name. - A cooldown stores the time when the player is ready. Remaining time is "ready time minus now". Start it only after the action succeeded.
- Round remaining time up and format it as "1m 30s" for players.
player.setCooldown(Material, ticks)draws the gray sweep, but only your own logic enforces a cooldown for custom items.- Remove entries when players quit, or the maps grow forever and toggles stick across sessions.
- All of this is lost on restart. Save to disk when it must last.
Quick quiz
What should the key of a per-player map be?
TheUUIDnever changes. APlayerobject stands for one login and keeps memory alive after the player leaves, and names can change.A player uses an ability at time 10,000 ms with a 5,000 ms cooldown. They try again at 12,000 ms. What happens?
Ready time = 10,000 + 5,000 = 15,000. Remaining = 15,000 - 12,000 = 3,000. The remaining time shrinks as the clock moves.Why use
player.setCooldown(Material.FEATHER, ticks)together with aCooldownsmap?For a custom ability on an item like a feather, the visual does not stop anything on its own. Your logic does, and the visual tells the player why.What does
set.remove(id)return in the build mode toggle?That return value letstogglework out in one line whether the player was on, and so should now be turned off.Why clear a player's entries in a
PlayerQuitEventhandler?Nothing breaks if you forget, which is why it is easy to forget. The cost is a slow leak and surprising leftover state.
Next steps
- Permissions: decide who gets to use your dash and build mode, and give ranks different cooldowns.
- Project: /heal and /feed with cooldowns: use the Cooldowns class in a full small project.
- Saving data on things: PersistentDataContainer: make state survive restarts.