Events
Events are how the engine tells a plugin that something happened, and how a plugin changes the outcome. They are synchronous, run on the tick thread, and use blittable structs passed by ref: no boxing, no allocation, no GC pressure.
[PluginInfo("EventsDemo", Version = "1.0.0")]public sealed class EventsDemoPlugin : Plugin{ protected override void OnEnable() { // cancel chat that contains "spam": the engine drops the message Events.On((ref PlayerChatEvent e) => { if (e.Message.Contains("spam", StringComparison.OrdinalIgnoreCase)) e.Cancelled = true; });
// observe the final result, whoever cancelled it Events.On((ref PlayerChatEvent e) => Log.Info(e.Cancelled ? "blocked" : "delivered"), EventPriority.Monitor, receiveCancelled: true);
// halve all damage dealt or taken Events.On((ref EntityDamageEvent e) => e.Amount *= 0.5f); }}Events are reached through plugin.Events (an EventRegistry) inside a Plugin subclass.
Subscribing
Section titled “Subscribing”Events.On<T>(listener, priority, receiveCancelled) returns a Subscription. You rarely keep it: unloading the plugin unsubscribes everything. Dispose it to unsubscribe earlier, and check IsActive if you need to know.
Subscription sub = Events.On((ref PlayerJoinEvent e) => { ... });sub.Dispose(); // optionalThe handler is a plain delegate, EventListener<T>(ref T ev). There is no reflection-based listener scanning and no @EventHandler annotation. Events.HasSubscribers<T>() tells you whether anyone listens, so you can skip building an expensive event.
Priorities
Section titled “Priorities”Lower runs first, and native and managed handlers share one ordering. The conventional values are EventPriority:
| Priority | Value | Use it to |
|---|---|---|
Lowest |
-200 | see the event before anyone changes it |
Low |
-100 | |
Normal |
0 | the default |
High |
100 | override other plugins; act on a nearly final state |
Highest |
200 | last chance to change the outcome |
Monitor |
1000 | observe the final outcome; must not modify the event |
The order is the same as Paper’s (LOWEST first, MONITOR last). Any int is allowed ((EventPriority)150).
Cancellation
Section titled “Cancellation”A catalogue event is either cancellable or not (IsCancellable, shown in the catalogue). On a cancellable event a handler sets e.Cancelled = true:
- later handlers are skipped, unless they subscribed with
receiveCancelled: true; - the code that fired the event sees the result and acts on it (
PlayerChatcancelled: the message is dropped;PlayerMovecancelled: the player is teleported back); - setting
Cancelledon a non-cancellable event does nothing.
The Monitor handler above is the usual way to react to the final outcome. It must not change the event.
Mutable fields
Section titled “Mutable fields”Most event fields are readonly. A few are mutable: a handler may change them and the engine reads the new value back after the dispatch. Numbers, booleans and enums are assigned directly (PlayerMoveEvent.To, EntityDamageEvent.Amount, BlockBreakEvent.DropItems). Strings and rich text are changed through Set methods, because the engine copies them into a per-tick buffer: SetMessage(string) on chat, SetJoinMessage(Component) on join, SetCommand(string) on a command, SetMotd(Component) on the server list ping. The catalogue and the event explorer show which fields are mutable.
Events.On((ref PlayerJoinEvent e) => e.SetJoinMessage(Component.Text($"{e.Username.ToString()} arrived")));Text fields you only read (Username, Message …) are Utf8String: see below.
Borrowed strings and bytes
Section titled “Borrowed strings and bytes”Text fields such as Message, Username and Command are Utf8String: a view of native memory that is only valid during the dispatch. Reading it allocates nothing until you ask for a string.
[PluginInfo("BorrowedText", Version = "1.0.0")]public sealed class BorrowedTextPlugin : Plugin{ protected override void OnEnable() { Events.On((ref PlayerChatEvent e) => { if (e.Message.IsEmpty) return; if (e.Message.EqualsAscii("!help"u8)) { /* byte compare, no allocation */ } string text = e.Message.ToString(); // allocates; safe to keep after the handler returns Log.Debug(text); }); }}Utf8String offers Bytes (a ReadOnlySpan<byte>), Length, IsEmpty, Contains(text, comparison), EqualsAscii(bytes) and ToString(). Never store the Utf8String, the NativeBytes or the ref event itself: the compiler refuses to capture a ref in a lambda, and the other two are ref structs too. Copy first.
Holding a login
Section titled “Holding a login”PlayerPreLoginEvent is the one event that may be answered later. Call Defer() in the handler, which returns a LoginDeferral, then call Allow() or Deny(reason) from any thread. The login waits for every deferral (up to 30 seconds) and is refused as soon as one denies it. A plugin that unloads releases its pending deferrals as allowed.
Which events exist, and which fire
Section titled “Which events exist, and which fire”The catalogue has 78 events. They are generated from one source of truth that the engine’s C++ headers and the C# structs share, and each has a firing site in the engine today (see the generated status page, the full catalogue and the event explorer).
Some events from Paper’s list are not declared yet, because the engine feature behind them does not exist: BlockExplode, EntityExplode, EntityTarget, EntityPickupItem, EntityTame, EntityBreed, EntityTransform, StructureGrow and LightningStrike. A plugin cannot subscribe to them until they are declared. Everything that is declared fires.
Custom events
Section titled “Custom events”A plugin can define its own events and fire them on the same bus, so other plugins (and native code) can listen:
public struct HomeSetEvent : IEvent{ public EventHeader Header; // must be the first field public Entity Player; public int HomeCount;
public static string Key => "homes:set"; // "plugin:name" public static bool IsCancellable => true;}[PluginInfo("HomeEvents", Version = "1.0.0")]public sealed class HomeEventsPlugin : Plugin{ protected override void OnEnable() { // another plugin (or this one) can cancel a home that would be the eleventh Events.On((ref HomeSetEvent e) => { if (e.HomeCount > 10) e.Header.Cancelled = true; }); }
private void SetHome(Player player, int count) { var ev = new HomeSetEvent { Player = player, HomeCount = count }; if (!Events.Fire(ref ev)) return; // false: a handler cancelled it // store the home }}- The struct must be blittable (no references or strings). Ids from 512 are assigned at runtime by key.
- Registering the same key with a different size or cancellability while someone listens is rejected.
- Native and managed handlers interleave by priority, exactly as for catalogue events.
Events.Firemust be called on the tick thread.
Errors
Section titled “Errors”An exception that escapes a handler is caught at the native boundary and logged with the plugin’s name and the stack trace, and dispatching continues with the next handler. A bug in one plugin cannot take the server down. A handler that keeps throwing (more than 100 exceptions in 20 seconds) is switched off until the plugin is reloaded, and the log says so.
Firing an event nobody listens to costs about 0.4 ns (one bit test); an event with a C# handler costs more, and more again when plugins run in the separate worker process (the numbers are on the performance page). A handler that allocates is where the real cost is; see performance. A handler that blocks the tick thread for more than 50 ms is reported in the log, so move slow work to Scheduler.RunAsync (see threading). Subscribe to the events you need, not to PlayerMoveEvent for a rarely needed feature.