LegacyShell WikiLegacyShell Wiki
Back to LegacyShell
Home
Wiki
Plugins
Docs
Back to LegacyShell
Home
Wiki
Plugins
Docs
  • Documentation

    • Getting Started

      • What is LegacyShell?
      • Speed Setup
      • Requirements
      • Installation
      • First Run
      • Config Files
      • Making an Account
      • Troubleshooting (Getting Started)
    • Running a Server

      • Architecture Overview
      • The Database
      • Users and Ranks
      • Adding Game Servers
      • Client Mirrors
      • Perpetual
      • Backups
      • Rate Limiting
      • Moderation
      • Closed Mode
      • Deployment
      • Troubleshooting (Running a Server)
      • Hosting for Someone Else's Instance
    • Content Creation

      • Maps
      • Dealing with Babylon Models
      • Map Blocks
      • Items and Skins
      • Hats and Stamps
      • Sounds
      • Gamemodes
      • Seasonal Events
    • Plugin Development

      • Quickstart
      • I Want To...
      • Anatomy of a Plugin
      • Lifecycle
      • Dependencies
      • Events (Concept)
      • Event Reference

        • services: events
        • game: events — shared logic (src/shell/)
        • game: events — main-thread server process
        • game: events — per-connection client object
        • game: events — room lifecycle & tick loop
        • game: events — in-browser gameplay
        • client: events — client server & build pipeline
      • Commands
      • Client-Side Code
      • Static Assets
      • Content Packs
      • Networking
      • Workers and State
      • Prediction and Authority
      • Recipes

        • Recipe: Killstreaks
        • Recipe: New Pickup Item
        • Recipe: New Gamemode
        • Recipe: Custom Weapon
        • Recipe: UI Modification
        • Recipe: Discord Integration
        • Recipe: Replacing Core Behaviour
        • Recipe: Persistent Plugin Storage
        • Recipe: Rewarding Players with Currency
        • Recipe: Custom Per-Player Data
        • Recipe: Custom Theme
      • Publishing
      • Pitfalls
      • Modifiers
      • Sound and Apollo
    • Codebase Reference

      • Repo Layout
      • Shared Shell Layer
      • Server-Only Markers
      • The ss Object
      • Build Pipeline
      • Stamps and Babylons
      • Game Loop
      • Rooms and Workers
      • Wire Protocol
      • Generated

        • Wire Protocol Opcodes
        • Enums & Lookup Tables
        • Database Schema
        • Config Reference
        • Slash Command Reference
      • Services Internals
      • Catalog and Items
      • Permissions Internals
      • Physics and Collision
      • Known Quirks
      • Codebase Anecdotes
      • Development Timeline
    • Contributing

      • Documentation Style Guide
      • Generators
      • For AI Agents

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

plugins.emit('eventName', payload)this.cancel = falseevent = `${this.type}:${eventName}`for each listener in listeners[event], in registration orderListener 1await listener(...args) - try/catch, errors logged not thrownListener 2may set plugins.cancel = true... more listeners, sequential, not parallelplugins.cancelafter loop?falsetrueDefault behavior runse.g. if (!plugins.cancel)Bullet.fire(...)Default behavior skippeda listener fully replaced it -see "opting out" below

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:

  1. on uses the full, prefixed event name (game:onPlayerDeath) - you always type the prefix yourself when registering a listener.
  2. emit is called with the unprefixed name at the call site (onPlayerDeath) - PluginManager.emit adds 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 async listener 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.

Edit this page on GitHub
Prev
Dependencies