Events (Concept)
Audience: Plugin authors · Prereqs: Dependencies
Canonical source:
src/shell/plugins.js(PluginManager.on,.emit)
The entire plugin API is one mechanism: a PluginManager with on(event, listener) and emit(event, ...args). Everything else in this codebase - commands, content packs, client code injection - is just a convention built on top of this one primitive. This page covers the mechanism itself; see the Event Reference for the actual list of events available.
At a glance
on and emit
this.plugins.on('game:onPlayerDeath', this.handleDeath.bind(this));
// (core code, not something you write)
plugins.emit('onPlayerDeath', { player, firedId });
Two things to notice immediately:
onuses the full, prefixed event name (game:onPlayerDeath) - you always type the prefix yourself when registering a listener.emitis called with the unprefixed name at the call site (onPlayerDeath) -PluginManager.emitadds the${this.type}:prefix automatically before checking for listeners.
this.type is whichever server type is currently loading plugins ('services', 'game', or 'client' - see Lifecycle). This is why the same event name, emitted from shared code that runs on multiple server types, ends up firing under different prefixes depending on which process is running it - eventsInit and the shop-rotation events are the clearest examples of this in the actual event catalog.
Multiple listeners, one event
Nothing stops two plugins (or two listeners in the same plugin) from registering on the same event - PluginManager.listeners[event] is just an array, and emit awaits each one in the order they were registered (which, since registration happens inside each plugin's constructor, follows plugin load order - see Lifecycle).
async emit(event, ...args) {
this.cancel = false;
event = `${this.type}:${event}`;
if (this.listeners[event]) {
for (const listener of this.listeners[event]) {
try {
if (isObject(args[0])) args[0].EVENT = event;
await listener(...args, this);
} catch (error) {
console.error(`Error in listener for event ${event}:`, error);
};
};
};
};
Two consequences worth knowing:
- A listener that throws doesn't stop the others. Each listener is individually try/caught - one broken plugin logs an error but doesn't prevent the next listener (or the emitting code's own default behavior) from running.
- Listeners run in registration order, awaited sequentially, not in parallel. A slow
asynclistener genuinely delays every listener registered after it for that same event, and delays whatever the emitting code does next.
The payload
Whatever object you passed as the first argument to emit is what your listener receives, with one addition: args[0].EVENT gets set to the fully-prefixed event name before your listener runs (only if the first argument is an object - primitives are left alone). This is occasionally useful if one listener function is registered against several different events and needs to know which one just fired.
A near-universal convention in this codebase: the emitting object passes itself as this inside the payload (plugins.emit('roomInit', { this: this })), so your handler can reach back into the room/client/permissions instance that's currently running:
somePlugin(data) {
const room = data.this; // the RoomConstructor instance
room.notify("hello from a plugin");
};
This is why you'll see var ctx = data.this; or similar destructuring throughout real plugin code - data.this is how you get at the actual live object, not just whatever narrower fields happened to be included in the emit call.
plugins.cancel - opting out of default behavior
Some emit call sites are followed immediately by the core code's own default action, guarded by a check of plugins.cancel:
// (core code)
plugins.emit("fireEggk47", { this: this, pos, dir, Eggk47 });
if (!plugins.cancel) Bullet.fire(pos, dir, this);
emit() resets this.cancel = false at the very start of every call. A listener sets it to true to tell that specific emitting code to skip its own default follow-up:
someHandler(data) {
// ...do something instead of the default...
this.plugins.cancel = true;
};
This is the mechanism behind LegacyShell's most powerful plugin capability - fully replacing core behavior rather than just reacting to it. Two examples of how far this can go: a plugin can cancel the default minification step in the client build pipeline and substitute its own obfuscation pipeline entirely (a different tool, a different pass structure - whatever it wants), or cancel the default player-sync packet building and substitute its own logic, such as occlusion-based filtering that omits players a given client shouldn't be able to see (a common anti-cheat technique against ESP-style cheats).
cancel is one flag, shared by every listener on that event
It isn't scoped per-listener. If two plugins both listen to the same event and only one of them means to cancel the default, the other one's listener still runs against a plugins.cancel that's already true by the time it checks it (since listeners run sequentially and the flag is set as soon as any earlier listener sets it) - and any code checking plugins.cancel after your listener runs sees whatever the last listener left it as. Only set it when you specifically mean to suppress the default for that particular emit, and be aware that plugin load order (see Lifecycle) determines which listener runs, and therefore which one's cancel decision "wins" if they disagree.
onConstructor - a small convenience wrapper
this.on = plugins.onConstructor(PluginMeta);
plugins.onConstructor(pluginMeta) returns a bound version of on that automatically tags your registrations with pluginMeta.identifier for logging purposes, instead of the default "<anonymous>". Purely cosmetic (it changes what shows up in the registering emitter boot log lines) - most plugins in this codebase skip it and just call this.plugins.on(...) directly, which is equally correct.
What's next
The Event Reference is the actual list of events - what fires, from where, with what payload. This page was the mechanism; that's the vocabulary.
For the two biggest event-driven capabilities specifically, see their own pages: Commands (built on permissionsAfterSetup) and Client-Side Code (built on pluginSourceInsertion).
This page was drafted with AI assistance and reviewed for accuracy. If something looks wrong, please open a PR or flag it.
