Paper Plugin Guide
File mode0

Paper essentials

Making your own events

Create events other parts of your plugin (or other plugins) can listen to.

Intermediate36 min read

Paper's events tell your plugin what happens in the game. Your own events let your plugin tell other code what happens in your plugin: "this player just joined for the first time", "this player reached a diamond milestone". On this page you will write two custom events, fire them, listen to them, and make one that listeners can cancel and change.

Why make your own events?

Imagine you want three things to happen when a brand new player joins: they get a starter kit, everyone sees a welcome announcement, and later maybe a tutorial starts. You could put all three in one big PlayerJoinEvent handler. It works, but the handler grows with every feature, and every feature has to repeat the "is this the first join?" check.

A custom event splits the job in two:

  • One place decides that something happened, and announces it by firing the event.
  • Any number of listeners react, each in its own small class, without knowing about each other.

Programmers call this decoupling: the parts of your plugin depend on the event, not on each other. Adding a fourth feature means adding one more listener, and nothing else changes. It also opens your plugin to other plugins: they can listen to your events exactly like they listen to Paper's, and even cancel or change them. That is how big plugins such as economy, protection and minigame plugins let others build on top of them.

Your first custom event

The FirstJoin plugin fires a PlayerFirstJoinEvent whenever a player joins the server for the very first time. An event is a normal Java class with a few required parts. Here is all of it:

PlayerFirstJoinEvent.javaCompiles on Paper 26.3Compile Lab
Where this file livescustom-events-firstsrcmainjavacomexamplecustomeventsfirstPlayerFirstJoinEvent.java

The package com.example.customeventsfirst is the folder path com/example/customeventsfirst 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.

  • custom-events-first/
    • src/main/
      • java/com/example/customeventsfirst/Package com.example.customeventsfirst
        • FirstJoinAnnouncer.javaListener: reacts to events
        • FirstJoinDetector.javaListener: reacts to events
        • FirstJoinPlugin.javaMain class: Paper starts here (named as main in plugin.yml)
        • PlayerFirstJoinEvent.javayou are hereCustom event
        • StarterKitListener.javaListener: reacts to events
      • resources/Files copied into the jar as they are
        • plugin.ymlTells Paper the plugin's name, version and main class
    • 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

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.customeventsfirst;2 3import org.bukkit.entity.Player;4import org.bukkit.event.Event;5import org.bukkit.event.HandlerList;6 7public final class PlayerFirstJoinEvent extends Event {8 9    private static final HandlerList HANDLERS = new HandlerList();10 11    private final Player player;12 13    public PlayerFirstJoinEvent(Player player) {14        this.player = player;15    }16 17    public Player getPlayer() {18        return player;19    }20 21    @Override22    public HandlerList getHandlers() {23        return HANDLERS;24    }25 26    public static HandlerList getHandlerList() {27        return HANDLERS;28    }29}
  1. Every event extends Event, directly or through a parent like PlayerEvent. That is what lets Paper deliver it to listeners. By convention, the class name ends with Event.
  2. The handler list: the list of every @EventHandler method registered for this event. It is static, so there is exactly one list for the whole event class, shared by every event object you create.
  3. The information the event carries. Listeners need to know which player joined, so the event stores it. final because it never changes.
  4. The constructor. The code that fires the event passes the player in here.
  5. A getter, so listeners can read the player with event.getPlayer(), just like with Paper's events.
  6. Required by Event. Paper calls it on an event object to find the listeners. It must return the static list.
  7. Also required, and easy to forget because the compiler does not check it. Paper looks for this exact static method when a listener for this event is registered.

Every custom event has this same skeleton. The only parts that change from event to event are the class name and the information it carries. The handler-list part is boilerplate: copy it as it is.

Firing the event

An event does nothing until something fires it. This listener watches Paper's PlayerJoinEvent and fires the custom event for first joins only:

FirstJoinDetector.javaCompiles on Paper 26.3Compile Lab
Where this file livescustom-events-firstsrcmainjavacomexamplecustomeventsfirstFirstJoinDetector.java

The package com.example.customeventsfirst is the folder path com/example/customeventsfirst 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.

  • custom-events-first/
    • src/main/
      • java/com/example/customeventsfirst/Package com.example.customeventsfirst
        • FirstJoinAnnouncer.javaListener: reacts to events
        • FirstJoinDetector.javayou are hereListener: reacts to events
        • FirstJoinPlugin.javaMain class: Paper starts here (named as main in plugin.yml)
        • PlayerFirstJoinEvent.javaCustom event
        • StarterKitListener.javaListener: reacts to events
      • resources/Files copied into the jar as they are
        • plugin.ymlTells Paper the plugin's name, version and main class
    • 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

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.customeventsfirst;2 3import org.bukkit.entity.Player;4import org.bukkit.event.EventHandler;5import org.bukkit.event.Listener;6import org.bukkit.event.player.PlayerJoinEvent;7 8public final class FirstJoinDetector implements Listener {9 10    @EventHandler11    public void onJoin(PlayerJoinEvent event) {12        Player player = event.getPlayer();13        if (player.hasPlayedBefore()) {14            return;15        }16        PlayerFirstJoinEvent firstJoin = new PlayerFirstJoinEvent(player);17        firstJoin.callEvent();18    }19}
  1. A normal listener for a Paper event. This is the one place that decides what "first join" means.
  2. Returning players are ignored. The check lives here once, instead of in every feature.
  3. Creates the event object with the information listeners need.
  4. Fires the event. Paper runs every registered handler for PlayerFirstJoinEvent, in priority order, and only then does this line finish.

callEvent() is a Paper shortcut. In older code you will see the same thing written as Bukkit.getPluginManager().callEvent(firstJoin); both do the same job.

Listening to it

Listening to a custom event is exactly the same as listening to a Paper event. The parameter type is your event class:

StarterKitListener.javaCompiles on Paper 26.3Compile Lab
Where this file livescustom-events-firstsrcmainjavacomexamplecustomeventsfirstStarterKitListener.java

The package com.example.customeventsfirst is the folder path com/example/customeventsfirst 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.

  • custom-events-first/
    • src/main/
      • java/com/example/customeventsfirst/Package com.example.customeventsfirst
        • FirstJoinAnnouncer.javaListener: reacts to events
        • FirstJoinDetector.javaListener: reacts to events
        • FirstJoinPlugin.javaMain class: Paper starts here (named as main in plugin.yml)
        • PlayerFirstJoinEvent.javaCustom event
        • StarterKitListener.javayou are hereListener: reacts to events
      • resources/Files copied into the jar as they are
        • plugin.ymlTells Paper the plugin's name, version and main class
    • 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

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.customeventsfirst;2 3import org.bukkit.Material;4import org.bukkit.entity.Player;5import org.bukkit.event.EventHandler;6import org.bukkit.event.Listener;7import org.bukkit.inventory.ItemStack;8 9public final class StarterKitListener implements Listener {10 11    @EventHandler12    public void onFirstJoin(PlayerFirstJoinEvent event) {13        Player player = event.getPlayer();14        player.getInventory().addItem(ItemStack.of(Material.STONE_SWORD), ItemStack.of(Material.BREAD, 8));15        player.sendRichMessage("<green>Here is a starter kit. Good luck out there!");16    }17}
  1. The parameter type tells Paper which event this handler wants, the same as with BlockBreakEvent or any other event.
  2. Uses the getter from the event class.
  3. Puts a stone sword and 8 bread into the player's inventory.
FirstJoinAnnouncer.javaCompiles on Paper 26.3Compile Lab
Where this file livescustom-events-firstsrcmainjavacomexamplecustomeventsfirstFirstJoinAnnouncer.java

The package com.example.customeventsfirst is the folder path com/example/customeventsfirst 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.

  • custom-events-first/
    • src/main/
      • java/com/example/customeventsfirst/Package com.example.customeventsfirst
        • FirstJoinAnnouncer.javayou are hereListener: reacts to events
        • FirstJoinDetector.javaListener: reacts to events
        • FirstJoinPlugin.javaMain class: Paper starts here (named as main in plugin.yml)
        • PlayerFirstJoinEvent.javaCustom event
        • StarterKitListener.javaListener: reacts to events
      • resources/Files copied into the jar as they are
        • plugin.ymlTells Paper the plugin's name, version and main class
    • 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

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.customeventsfirst;2 3import net.kyori.adventure.text.minimessage.MiniMessage;4import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder;5import org.bukkit.Bukkit;6import org.bukkit.event.EventHandler;7import org.bukkit.event.Listener;8 9public final class FirstJoinAnnouncer implements Listener {10 11    @EventHandler12    public void onFirstJoin(PlayerFirstJoinEvent event) {13        Bukkit.broadcast(MiniMessage.miniMessage().deserialize(14            "<gold>Everyone welcome <yellow><name></yellow>, who just joined for the first time!",15            Placeholder.unparsed("name", event.getPlayer().getName())));16    }17}
  1. Sends the message to every online player and the console.
  2. Inserts the player's name as plain text into the <name> tag.

Neither listener knows about the other, or about how "first join" is detected. The main class registers all three listeners like any others:

FirstJoinPlugin.javaCompiles on Paper 26.3Compile Lab
Where this file livescustom-events-firstsrcmainjavacomexamplecustomeventsfirstFirstJoinPlugin.java

The package com.example.customeventsfirst is the folder path com/example/customeventsfirst 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.

  • custom-events-first/
    • src/main/
      • java/com/example/customeventsfirst/Package com.example.customeventsfirst
        • FirstJoinAnnouncer.javaListener: reacts to events
        • FirstJoinDetector.javaListener: reacts to events
        • FirstJoinPlugin.javayou are hereMain class: Paper starts here (named as main in plugin.yml)
        • PlayerFirstJoinEvent.javaCustom event
        • StarterKitListener.javaListener: reacts to events
      • resources/Files copied into the jar as they are
        • plugin.ymlTells Paper the plugin's name, version and main class
    • 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

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.customeventsfirst;2 3import org.bukkit.plugin.PluginManager;4import org.bukkit.plugin.java.JavaPlugin;5 6public final class FirstJoinPlugin extends JavaPlugin {7 8    @Override9    public void onEnable() {10        PluginManager pluginManager = getServer().getPluginManager();11        pluginManager.registerEvents(new FirstJoinDetector(), this);12        pluginManager.registerEvents(new StarterKitListener(), this);13        pluginManager.registerEvents(new FirstJoinAnnouncer(), this);14    }15}
  1. The listener that fires the custom event is registered like any other listener.
  2. The two listeners that react to it.

When Alex joins for the first time, everyone sees the announcement, and Alex also gets the kit message:

Alex joined the game
Everyone welcome Alex, who just joined for the first time!
Here is a starter kit. Good luck out there!
  • custom-events-first/
    • src/
      • main/
        • java/
          • com/example/customeventsfirst/
            • FirstJoinPlugin.javaMain class: registers the three listeners
            • PlayerFirstJoinEvent.javaThe custom event: carries the player
            • FirstJoinDetector.javaListens to PlayerJoinEvent and fires PlayerFirstJoinEvent
            • StarterKitListener.javaReacts by giving a starter kit
            • FirstJoinAnnouncer.javaReacts by announcing the new player
        • resources/
          • plugin.ymlName, version, main class, api-version

Events that can be canceled and changed

The most useful custom events fire before your plugin does something, so listeners get a say. Paper's own BlockBreakEvent works this way: it fires before the block breaks, and listeners can cancel it. Your events can work the same way.

The DiamondMilestones plugin counts diamond ores each player mines. Every 10 ores, it wants to give the player experience and announce it. Before it does, it fires a DiamondMilestoneEvent, so that:

  • a listener can cancel the milestone (no rewards in creative mode),
  • a listener can change the reward (double experience for VIPs),
  • a listener can add something (a bonus diamond every 50 ores),
  • and other plugins can do the same without touching your code.

The template project example-events in paper-templates contains a smaller version of this event, if you want to compare.

DiamondMilestoneEvent.javaCompiles on Paper 26.3Compile Lab
Where this file livescustom-events-milestonesrcmainjavacomexamplecustomeventsmilestoneDiamondMilestoneEvent.java

The package com.example.customeventsmilestone is the folder path com/example/customeventsmilestone 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.

  • custom-events-milestone/
    • src/main/
      • java/com/example/customeventsmilestone/Package com.example.customeventsmilestone
        • DiamondMilestoneEvent.javayou are hereCustom event
        • JackpotListener.javaListener: reacts to events
        • MilestonePlugin.javaMain class: Paper starts here (named as main in plugin.yml)
        • MiningListener.javaListener: reacts to events
        • NoCreativeListener.javaListener: reacts to events
        • VipBonusListener.javaListener: reacts to events
      • resources/Files copied into the jar as they are
        • plugin.ymlTells Paper the plugin's name, version and main class
    • 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

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.customeventsmilestone;2 3import org.bukkit.entity.Player;4import org.bukkit.event.Cancellable;5import org.bukkit.event.HandlerList;6import org.bukkit.event.player.PlayerEvent;7 8public final class DiamondMilestoneEvent extends PlayerEvent implements Cancellable {9 10    private static final HandlerList HANDLERS = new HandlerList();11 12    private final int total;13    private int rewardXp;14    private boolean canceled;15 16    public DiamondMilestoneEvent(Player player, int total, int rewardXp) {17        super(player);18        this.total = total;19        this.rewardXp = rewardXp;20    }21 22    public int getTotal() {23        return total;24    }25 26    public int getRewardXp() {27        return rewardXp;28    }29 30    public void setRewardXp(int rewardXp) {31        this.rewardXp = Math.max(0, rewardXp);32    }33 34    @Override35    public boolean isCancelled() {36        return canceled;37    }38 39    @Override40    public void setCancelled(boolean cancel) {41        this.canceled = cancel;42    }43 44    @Override45    public HandlerList getHandlers() {46        return HANDLERS;47    }48 49    public static HandlerList getHandlerList() {50        return HANDLERS;51    }52}
  1. Two upgrades. PlayerEvent is Paper's parent class for events about a player: it stores the player and provides getPlayer(), so this class does not have to. Cancellable is the interface that makes an event cancelable.
  2. How many ores the player has mined. Listeners may read it but not change it, so it is final and has no setter.
  3. The reward. Not final, because listeners are allowed to change it.
  4. Remembers whether a listener canceled the event. A new boolean field starts as false.
  5. Hands the player to PlayerEvent, which stores it for getPlayer().
  6. The setter listeners use to change the reward. Math.max(0, ...) stops a listener from setting a negative reward by mistake. Events are an API other people use, so guard against bad values.
  7. Required by Cancellable. The API spells it with two l's; keep that spelling for the method names.
  8. Also required by Cancellable. Listeners call this to cancel, and it only flips the field.
  9. The same handler-list boilerplate as before. Every event class needs its own.

Firing it and acting on the answer

For a cancelable event, callEvent() returns an answer: true if the event was not canceled, false if a listener canceled it. The firing code then does its job only when allowed, and reads back any values listeners changed:

MiningListener.javaCompiles on Paper 26.3Compile Lab
Where this file livescustom-events-milestonesrcmainjavacomexamplecustomeventsmilestoneMiningListener.java

The package com.example.customeventsmilestone is the folder path com/example/customeventsmilestone 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.

  • custom-events-milestone/
    • src/main/
      • java/com/example/customeventsmilestone/Package com.example.customeventsmilestone
        • DiamondMilestoneEvent.javaCustom event
        • JackpotListener.javaListener: reacts to events
        • MilestonePlugin.javaMain class: Paper starts here (named as main in plugin.yml)
        • MiningListener.javayou are hereListener: reacts to events
        • NoCreativeListener.javaListener: reacts to events
        • VipBonusListener.javaListener: reacts to events
      • resources/Files copied into the jar as they are
        • plugin.ymlTells Paper the plugin's name, version and main class
    • 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

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.

25@EventHandler(ignoreCancelled = true)26public void onBreak(BlockBreakEvent event) {27    if (!DIAMOND_ORES.contains(event.getBlock().getType())) {28        return;29    }30    Player player = event.getPlayer();31    int total = diamondsMined.merge(player.getUniqueId(), 1, Integer::sum);32    if (total % MILESTONE_EVERY != 0) {33        return;34    }35 36    DiamondMilestoneEvent milestone = new DiamondMilestoneEvent(player, total, total * XP_PER_DIAMOND);37    if (!milestone.callEvent()) {38        return;39    }40 41    player.giveExp(milestone.getRewardXp());42    Bukkit.broadcast(MiniMessage.miniMessage().deserialize(43        "<aqua><name></aqua> <gray>mined <white><total></white> diamond ores and earned <green><xp> XP</green>!",44        Placeholder.unparsed("name", player.getName()),45        Placeholder.unparsed("total", String.valueOf(total)),46        Placeholder.unparsed("xp", String.valueOf(milestone.getRewardXp()))));47}
  1. If a protection plugin blocked the break, the ore was not mined and does not count. You learned this in Event priorities and canceling.
  2. Adds one to this player's count and returns the new total.
  3. Not a multiple of 10, so this is not a milestone. Stop here.
  4. The suggested reward: 5 experience per ore mined so far, so 50 at the first milestone.
  5. Fires the event and reads the answer. false means a listener canceled it, so the reward and announcement are skipped.
  6. Reads the reward after the listeners ran, so a VIP bonus is included. Never use the original number here.
  7. Announces the milestone with the final reward.
Your code Listeners, in priority order MiningListener 10th diamond ore: a milestone! new DiamondMilestoneEvent(...) milestone.callEvent() Paper hands it out LOW: NoCreativeListener cancels the milestone in creative mode NORMAL: VipBonusListener doubles the reward with setRewardXp HIGH: JackpotListener a bonus diamond every 50 ores any other plugin that listens back to you callEvent() returns true: give XP, announce false: canceled, stop
Firing a cancelable event: your code builds the event, Paper hands it to every listener in priority order, and callEvent() tells you whether to go ahead.

The listeners

Each listener is tiny and does one thing. The first one cancels:

NoCreativeListener.javaCompiles on Paper 26.3Compile Lab
Where this file livescustom-events-milestonesrcmainjavacomexamplecustomeventsmilestoneNoCreativeListener.java

The package com.example.customeventsmilestone is the folder path com/example/customeventsmilestone 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.

  • custom-events-milestone/
    • src/main/
      • java/com/example/customeventsmilestone/Package com.example.customeventsmilestone
        • DiamondMilestoneEvent.javaCustom event
        • JackpotListener.javaListener: reacts to events
        • MilestonePlugin.javaMain class: Paper starts here (named as main in plugin.yml)
        • MiningListener.javaListener: reacts to events
        • NoCreativeListener.javayou are hereListener: reacts to events
        • VipBonusListener.javaListener: reacts to events
      • resources/Files copied into the jar as they are
        • plugin.ymlTells Paper the plugin's name, version and main class
    • 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

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.customeventsmilestone;2 3import org.bukkit.GameMode;4import org.bukkit.event.EventHandler;5import org.bukkit.event.EventPriority;6import org.bukkit.event.Listener;7 8public final class NoCreativeListener implements Listener {9 10    @EventHandler(priority = EventPriority.LOW)11    public void onMilestone(DiamondMilestoneEvent event) {12        if (event.getPlayer().getGameMode() == GameMode.CREATIVE) {13            event.setCancelled(true);14        }15    }16}
  1. Canceling happens early, so the listeners after it can skip canceled milestones.
  2. Players in creative mode can place diamond ore and mine it again, so they get no milestone rewards.
  3. Makes callEvent() return false in MiningListener.

The second one changes the reward:

VipBonusListener.javaCompiles on Paper 26.3Compile Lab
Where this file livescustom-events-milestonesrcmainjavacomexamplecustomeventsmilestoneVipBonusListener.java

The package com.example.customeventsmilestone is the folder path com/example/customeventsmilestone 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.

  • custom-events-milestone/
    • src/main/
      • java/com/example/customeventsmilestone/Package com.example.customeventsmilestone
        • DiamondMilestoneEvent.javaCustom event
        • JackpotListener.javaListener: reacts to events
        • MilestonePlugin.javaMain class: Paper starts here (named as main in plugin.yml)
        • MiningListener.javaListener: reacts to events
        • NoCreativeListener.javaListener: reacts to events
        • VipBonusListener.javayou are hereListener: reacts to events
      • resources/Files copied into the jar as they are
        • plugin.ymlTells Paper the plugin's name, version and main class
    • 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

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.customeventsmilestone;2 3import org.bukkit.event.EventHandler;4import org.bukkit.event.Listener;5 6public final class VipBonusListener implements Listener {7 8    @EventHandler(ignoreCancelled = true)9    public void onMilestone(DiamondMilestoneEvent event) {10        if (event.getPlayer().hasPermission("milestones.vip")) {11            event.setRewardXp(event.getRewardXp() * 2);12            event.getPlayer().sendRichMessage("<gold>VIP bonus: double experience!");13        }14    }15}
  1. No bonus message for milestones that were canceled.
  2. A permission from plugin.yml with default: false, so nobody has it until a permissions plugin grants it.
  3. Reads the current reward and doubles it. Because it reads first instead of setting a fixed number, it still works if another listener changed the reward earlier.

A VIP reaching 20 diamond ores sees this. The broadcast shows the doubled reward because MiningListener reads it after the listeners ran:

VIP bonus: double experience!
Steve mined 20 diamond ores and earned 200 XP!

Designing good events

  • Name it after what happened, ending in Event: PlayerFirstJoinEvent, DiamondMilestoneEvent, ShopPurchaseEvent.
  • Fire it before the action if listeners should be able to stop or change it, and make it Cancellable. Fire it after the action if it is only news, like "a player finished a parkour run".
  • Carry everything a listener needs: the player, the amounts, the item. Listeners should not have to look things up in your plugin's internals.
  • Make fields final unless listeners are meant to change them, and check values in setters.
  • Always read changeable values back after callEvent(), and always respect the answer.

Async custom events

Most events are fired on the main thread. If your plugin does slow work on another thread, such as loading a player's data from a database, and wants to fire an event from there, the event must say that it is asynchronous. You do that by passing true to the parent constructor:

An async event's constructor
1public PlayerDataLoadedEvent(UUID playerId) {2    super(true);3    this.playerId = playerId;4}

The rest of the class looks exactly like PlayerFirstJoinEvent. The rules are strict, and Paper checks them: an async event must be fired from a thread that is not the main thread, and a normal event must be fired from the main thread. Getting it wrong throws an IllegalStateException. Firing an async event looks like this:

Firing it from an async task
1getServer().getScheduler().runTaskAsynchronously(this, () -> {2    new PlayerDataLoadedEvent(playerId).callEvent();3});

Listeners of async events follow the same rules as listeners of AsyncChatEvent: no touching the world without hopping back to the main thread first. Most plugins never need an async event; reach for one only when you are already working off the main thread. Threads, performance and lag explains threads.

Letting other plugins listen

Another plugin can listen to DiamondMilestoneEvent just like you do. It needs two things:

  1. Your plugin's classes when it compiles, added as a compileOnly dependency, the same way you depend on the Paper API. For a jar you share directly, that can be as simple as compileOnly(files("libs/custom-events-milestone-1.0.0.jar")) in its build.gradle.kts.
  2. depend: [DiamondMilestones] (or softdepend) in its plugin.yml, so Paper loads your plugin first. See plugin.yml explained.

Then it writes a normal @EventHandler for your event. Because the other plugin builds against your event class, renaming the class or its methods later breaks every plugin that uses it. Treat your event classes as a promise. Working with other plugins covers sharing an API in more detail.

Mistakes to avoid

  • Forgetting the static getHandlerList(). It compiles, then fails when a listener registers.
  • Creating a new HandlerList in getHandlers(), such as return new HandlerList();. Paper would find an empty list every time and nobody would hear the event. Always return the one static field.
  • Ignoring the result of callEvent() on a cancelable event. If you reward the player anyway, canceling does nothing.
  • Using the original value instead of the event's after firing, which throws away every listener's change.
  • Firing an async event from the main thread, or a normal event from an async task. Paper throws an exception.
  • Firing events in a tight loop. Every call runs every listener. Fire on meaningful moments, like a milestone, not on every tick.
Try it

A jackpot every 50 ores

Without changing MiningListener or the event class, add a listener that gives the player one extra diamond at every 50th diamond ore (50, 100, 150...). It should not pay out for canceled milestones.

Hint 1

The event already knows how many ores were mined: event.getTotal(). Which numbers divide by 50 with nothing left over?

Hint 2

Use ignoreCancelled = true, and a priority later than LOW so the creative check has already run. Remember to register the new listener in MilestonePlugin.

Show the solution

The new listener only reads the total and gives an item. Notice that you added a feature without touching the code that fires the event: that is the point of custom events.

JackpotListener.javaCompiles on Paper 26.3Compile Lab
Where this file livescustom-events-milestonesrcmainjavacomexamplecustomeventsmilestoneJackpotListener.java

The package com.example.customeventsmilestone is the folder path com/example/customeventsmilestone 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.

  • custom-events-milestone/
    • src/main/
      • java/com/example/customeventsmilestone/Package com.example.customeventsmilestone
        • DiamondMilestoneEvent.javaCustom event
        • JackpotListener.javayou are hereListener: reacts to events
        • MilestonePlugin.javaMain class: Paper starts here (named as main in plugin.yml)
        • MiningListener.javaListener: reacts to events
        • NoCreativeListener.javaListener: reacts to events
        • VipBonusListener.javaListener: reacts to events
      • resources/Files copied into the jar as they are
        • plugin.ymlTells Paper the plugin's name, version and main class
    • 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

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.customeventsmilestone;2 3import org.bukkit.Material;4import org.bukkit.entity.Player;5import org.bukkit.event.EventHandler;6import org.bukkit.event.EventPriority;7import org.bukkit.event.Listener;8import org.bukkit.inventory.ItemStack;9 10public final class JackpotListener implements Listener {11 12    private static final int JACKPOT_EVERY = 50;13 14    @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true)15    public void onMilestone(DiamondMilestoneEvent event) {16        if (event.getTotal() % JACKPOT_EVERY != 0) {17            return;18        }19        Player player = event.getPlayer();20        player.getInventory().addItem(ItemStack.of(Material.DIAMOND));21        player.sendRichMessage("<aqua>Jackpot! Here is a bonus diamond.");22    }23}
  1. Runs after the creative check at LOW and skips canceled milestones.
  2. A guard clause: only every 50th ore gets the jackpot.
  3. The bonus diamond.

And one more line in MilestonePlugin.onEnable: pluginManager.registerEvents(new JackpotListener(), this);.

Try it

Make the first join cancelable

Staff accounts should not get the starter kit. Change PlayerFirstJoinEvent so it can be canceled, add a LOW listener that cancels it for players with the permission firstjoin.staff, and make the starter kit listener skip canceled events. When the event is canceled, the detector should tell the player "Welcome, staff member. No starter kit for you."

Hint 1

Add implements Cancellable, a boolean cancelled field, and the two methods isCancelled() and setCancelled(boolean), exactly like DiamondMilestoneEvent.

Hint 2

In the detector, use the result of callEvent(): it is false when the event was canceled.

Show the solution

The event gains the Cancellable parts:

PlayerFirstJoinEvent.javaCompiles on Paper 26.3Compile Lab
Where this file livescustom-events-exercisesrcmainjavacomexamplecustomeventsexercisePlayerFirstJoinEvent.java

The package com.example.customeventsexercise is the folder path com/example/customeventsexercise 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.

  • custom-events-exercise/
    • src/main/
      • java/com/example/customeventsexercise/Package com.example.customeventsexercise
        • FirstJoinCancelablePlugin.javaMain class: Paper starts here (named as main in plugin.yml)
        • FirstJoinDetector.javaListener: reacts to events
        • PlayerFirstJoinEvent.javayou are hereCustom event
        • StaffSkipListener.javaListener: reacts to events
        • StarterKitListener.javaListener: reacts to events
      • resources/Files copied into the jar as they are
        • plugin.ymlTells Paper the plugin's name, version and main class
    • 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

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.customeventsexercise;2 3import org.bukkit.entity.Player;4import org.bukkit.event.Cancellable;5import org.bukkit.event.Event;6import org.bukkit.event.HandlerList;7 8public final class PlayerFirstJoinEvent extends Event implements Cancellable {9 10    private static final HandlerList HANDLERS = new HandlerList();11 12    private final Player player;13    private boolean canceled;14 15    public PlayerFirstJoinEvent(Player player) {16        this.player = player;17    }18 19    public Player getPlayer() {20        return player;21    }22 23    @Override24    public boolean isCancelled() {25        return canceled;26    }27 28    @Override29    public void setCancelled(boolean cancel) {30        this.canceled = cancel;31    }32 33    @Override34    public HandlerList getHandlers() {35        return HANDLERS;36    }37 38    public static HandlerList getHandlerList() {39        return HANDLERS;40    }41}
  1. The event can now be canceled.
  2. Starts as false: not canceled.
  3. What the staff listener calls.

The detector reads the answer:

FirstJoinDetector.javaCompiles on Paper 26.3Compile Lab
Where this file livescustom-events-exercisesrcmainjavacomexamplecustomeventsexerciseFirstJoinDetector.java

The package com.example.customeventsexercise is the folder path com/example/customeventsexercise 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.

  • custom-events-exercise/
    • src/main/
      • java/com/example/customeventsexercise/Package com.example.customeventsexercise
        • FirstJoinCancelablePlugin.javaMain class: Paper starts here (named as main in plugin.yml)
        • FirstJoinDetector.javayou are hereListener: reacts to events
        • PlayerFirstJoinEvent.javaCustom event
        • StaffSkipListener.javaListener: reacts to events
        • StarterKitListener.javaListener: reacts to events
      • resources/Files copied into the jar as they are
        • plugin.ymlTells Paper the plugin's name, version and main class
    • 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

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.

10@EventHandler11public void onJoin(PlayerJoinEvent event) {12    Player player = event.getPlayer();13    if (player.hasPlayedBefore()) {14        return;15    }16    if (!new PlayerFirstJoinEvent(player).callEvent()) {17        player.sendRichMessage("<gray>Welcome, staff member. No starter kit for you.");18    }19}
  1. Creates and fires the event in one line, and checks whether it was canceled.

The staff listener cancels early, and the kit listener skips canceled events:

StaffSkipListener.javaCompiles on Paper 26.3Compile Lab
Where this file livescustom-events-exercisesrcmainjavacomexamplecustomeventsexerciseStaffSkipListener.java

The package com.example.customeventsexercise is the folder path com/example/customeventsexercise 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.

  • custom-events-exercise/
    • src/main/
      • java/com/example/customeventsexercise/Package com.example.customeventsexercise
        • FirstJoinCancelablePlugin.javaMain class: Paper starts here (named as main in plugin.yml)
        • FirstJoinDetector.javaListener: reacts to events
        • PlayerFirstJoinEvent.javaCustom event
        • StaffSkipListener.javayou are hereListener: reacts to events
        • StarterKitListener.javaListener: reacts to events
      • resources/Files copied into the jar as they are
        • plugin.ymlTells Paper the plugin's name, version and main class
    • 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

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.

9@EventHandler(priority = EventPriority.LOW)10public void onFirstJoin(PlayerFirstJoinEvent event) {11    if (event.getPlayer().hasPermission("firstjoin.staff")) {12        event.setCancelled(true);13    }14}
  1. Cancels before the kit listener at NORMAL runs.
StarterKitListener.javaCompiles on Paper 26.3Compile Lab
Where this file livescustom-events-exercisesrcmainjavacomexamplecustomeventsexerciseStarterKitListener.java

The package com.example.customeventsexercise is the folder path com/example/customeventsexercise 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.

  • custom-events-exercise/
    • src/main/
      • java/com/example/customeventsexercise/Package com.example.customeventsexercise
        • FirstJoinCancelablePlugin.javaMain class: Paper starts here (named as main in plugin.yml)
        • FirstJoinDetector.javaListener: reacts to events
        • PlayerFirstJoinEvent.javaCustom event
        • StaffSkipListener.javaListener: reacts to events
        • StarterKitListener.javayou are hereListener: reacts to events
      • resources/Files copied into the jar as they are
        • plugin.ymlTells Paper the plugin's name, version and main class
    • 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

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.

11@EventHandler(ignoreCancelled = true)
  1. No kit for canceled first joins.

Download DiamondMilestones or download FirstJoin to try them on your test server.

Recap

  • Custom events let one part of your code announce something and any number of listeners react, in your plugin or in others.
  • An event class extends Event (or a parent like PlayerEvent), has a static HandlerList, and returns it from both getHandlers() and the static getHandlerList().
  • Fire an event with event.callEvent(); listen to it with a normal @EventHandler.
  • Implement Cancellable to let listeners cancel; callEvent() then returns false when canceled, and you must respect it.
  • Setters let listeners change values; read them back after firing.
  • Async events pass true to super(...) and must be fired off the main thread.

Quick quiz

  1. Which method must every custom event have, even though the compiler does not force you to write it?

  2. For a cancelable event, what does callEvent() return?

  3. A listener doubles the reward with event.setRewardXp(...). What should the firing code give the player?

  4. Why make DiamondMilestoneEvent extend PlayerEvent instead of Event?

  5. You fire an event created with super(true) from a normal @EventHandler on the main thread. What happens?

Next steps