docs · authoring
Listeners.
@Listener is a class decorator. @EventHandler is a method decorator. Together they replace the boilerplate of registering a Bukkit listener and route the event payload into a typed handler that runs in your script.
The shape
Decorate a class with @Listener and any method inside it with @EventHandler(Events.X). At decorator-evaluation time, the host registers an instance of your class as a Paper listener and binds each annotated method to the event you named. The class is instantiated with a zero-argument constructor; if you need state, put it on the instance.
@Listener
export class WardLifecycle {
@EventHandler(Events.PlayerJoinEvent)
async onJoin(e: PlayerJoinEvent) {
await applyToPlayer(e.getPlayer());
}
@EventHandler(Events.PlayerQuitEvent)
onQuit(e: PlayerQuitEvent) {
dropPlayer(String(e.getPlayer().getUniqueId()));
}
}The e parameter is the real Bukkit event object, not a snapshot. Calling e.getPlayer() hands back a live reference; mutating fields on the event (cancelling, setting cancelled message) takes effect.
Why Events.X instead of strings
You'll see two styles in older code: @EventHandler("PlayerJoinEvent") and @EventHandler(Events.PlayerJoinEvent). Both work. The enum form is preferred because the host has populated autocomplete for it from your live classpath — including any custom events emitted by plugins you declared in rune.jsonc. The string form is a fallback for dynamically-resolved events.
Async handlers
A handler can return a Promise. The event itself fires synchronously on the Paper main thread, but your async body continues on the Node event loop after the event has already been dispatched. This means:
- You can
awaita database lookup or HTTP fetch from inside a handler. - You cannot cancel the event from after the first
await— it's already past the point where Paper asks "was this cancelled?" - Calls back into the Bukkit API from async code must be scheduled on the main thread with
rune.runOnMain(() => ...).
@EventHandler(Events.PlayerJoinEvent)
async onJoin(e: PlayerJoinEvent) {
const player = e.getPlayer();
const profile = await fetchProfileFromMongo(player.getUniqueId());
// We're off the main thread now. Schedule the response back on.
rune.runOnMain(() => {
player.sendMessage(`welcome back, ${profile.title}`);
});
}Priorities and ignoreCancelled
Pass an options object as the second argument to @EventHandler when you need standard Bukkit priority semantics.
@EventHandler(Events.PlayerCommandPreprocessEvent, {
priority: "HIGHEST",
ignoreCancelled: true,
})
onCommand(e: PlayerCommandPreprocessEvent) {
if (e.getMessage().startsWith("/banned-command")) {
e.setCancelled(true);
}
}Valid priorities are LOWEST, LOW, NORMAL (default), HIGH, HIGHEST, MONITOR. Same semantics as Bukkit's enum.
Multiple handlers, one class
Bundle related handlers on the same class so they share state. The class is instantiated once per registration; every method on the instance sees the same this.
@Listener
export class ChatGate {
private mutedUntil = new Map<string, number>();
@EventHandler(Events.AsyncChatEvent)
onChat(e: AsyncChatEvent) {
const uuid = String(e.getPlayer().getUniqueId());
const until = this.mutedUntil.get(uuid);
if (until && Date.now() < until) {
e.setCancelled(true);
}
}
@EventHandler(Events.PlayerQuitEvent)
onQuit(e: PlayerQuitEvent) {
this.mutedUntil.delete(String(e.getPlayer().getUniqueId()));
}
}Reload caveat
The map above is instance state. On /rune reload the script re-evaluates and the map starts empty. If the mute window needs to outlive a reload, persist it through rune.store() or a database.
Imperative subscriptions
If you need a handler that you'll later remove dynamically, use the imperative API. rune.on() returns a subscription object with an unsubscribe() method.
const sub = rune.on(Events.PlayerInteractEvent, (e) => {
if (e.getAction() === "RIGHT_CLICK_BLOCK") {
e.getPlayer().sendMessage("clicked.");
}
});
// Later, in some other code path:
sub.unsubscribe();Decorator-style listeners are tied to the script's lifecycle and are torn down on reload automatically. Imperative subscriptions are too, but you can also tear them down before reload if your logic demands it.