Skip to content

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.

EventsDemo.cs
[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.

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(); // optional

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

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

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 (PlayerChat cancelled: the message is dropped; PlayerMove cancelled: the player is teleported back);
  • setting Cancelled on 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.

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.

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.

BorrowedText.cs
[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.

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.

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.

A plugin can define its own events and fire them on the same bus, so other plugins (and native code) can listen:

HomeSetEvent.cs
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;
}
HomeEvents.cs
[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.Fire must be called on the tick thread.

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.