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

Recipe: UI Modification

Audience: Plugin authors · Prereqs: Client-Side Code

Canonical source: server-client/src/client-static/src/shellshock.min.js (the setupComplete event)

Adding your own DOM element to the game's UI - a simple welcome banner shown once the player is fully loaded in, as the pattern for anything else you'd want to draw on screen outside the 3D scene itself (HUD elements, custom menus, notifications).

The hook

setupComplete fires once the startup poll loop confirms both authentication and page setup have finished - a reliable "the player is actually looking at a ready game" moment, later than page-load events that could fire before the player's own data is available:

// shellshock.min.js - real code
plugins.emit("setupComplete", {});

The plugin

// plugins/welcomebanner/shared.js
import { isClient } from '#constants';
import { plugins } from '#plugins';

export const WelcomeBanner = {
    registerListeners(pluginManager) {
        this.plugins = pluginManager;
        this.plugins.on('game:setupComplete', this.onSetupComplete.bind(this));
    },
    onSetupComplete() {
        const banner = document.createElement('div');
        banner.textContent = `Welcome, ${me?.name || 'Guest'}!`;
        banner.style.cssText = `
            position: fixed; top: 16px; left: 50%; transform: translateX(-50%);
            background: rgba(0,0,0,0.75); color: white; padding: 8px 20px;
            border-radius: 6px; font-family: sans-serif; font-size: 16px;
            z-index: 9999; pointer-events: none;
        `;
        document.body.appendChild(banner);
        setTimeout(() => banner.remove(), 3000);
    },
};

if (isClient) WelcomeBanner.registerListeners(plugins);
// plugins/welcomebanner/index.js
import path from 'node:path';
import { WelcomeBanner } from './shared.js';

export const PluginMeta = {
    identifier: "welcomebanner",
    name: 'Welcome Banner',
    author: 'you',
    version: '1.0.0',
    descriptionShort: 'Shows a brief welcome banner once the game finishes loading.',
    descriptionLong: 'A minimal example of injecting a custom DOM element into the game UI.',
    legacyShellVersion: 598,
};

export class Plugin {
    constructor(plugins, thisDir) {
        this.plugins = plugins;
        this.thisDir = thisDir;

        // this plugin only does client-side work - no server-side listeners to register

        this.plugins.on('client:pluginSourceInsertion', this.pluginSourceInsertion.bind(this));
    };

    pluginSourceInsertion(data) {
        data.pluginInsertion.files.push({
            filepath: path.join(this.thisDir, 'shared.js'),
            position: 'after',
        });
    };
};

Why me works here, and why position: 'after' still matters

me is the browser bundle's global reference to the local player's own object (the same global parseMentions reads for @m - see Permissions Internals) - it only has a meaningful value once the player has actually joined a game, which setupComplete firing already guarantees. But the reference itself (the global binding) also only exists once the relevant shared module has loaded - so this still needs position: 'after' for the same reason as Custom Weapon: anything that reads an actual game object, not just registers a listener for later, needs to run after the full bundle is assembled.

Plain DOM APIs, nothing game-engine-specific

This recipe deliberately doesn't touch Babylon.js at all - document.createElement/appendChild/inline styles are enough for anything that's genuinely a 2D overlay rather than something rendered in the 3D scene. Reach for Babylon's own GUI/texture system only if you actually need something integrated into the 3D world (a name tag above a player, a marker in space) rather than a screen-space overlay like this.

What we validated

Loaded against a real (isolated, scratch) client server: the build pipeline successfully locates and splices shared.js into the bundle at the after position with no insertion error - the same mechanism validated in Custom Weapon, which also notes the pre-existing, unrelated minification issue that prevented us from getting a fully built bundle to visually confirm the banner appears on screen in this particular test environment.

Common Issues

The banner never appears. Confirm setupComplete is actually firing - add a console.log at the top of onSetupComplete and check the browser console; if it never logs, the listener registration itself likely failed (check for a JS error earlier in the console, which would prevent the rest of the bundle - including your plugin's spliced-in code - from running correctly).

Styling looks wrong or gets overridden by the game's own CSS. The inline style.cssText approach used here is deliberately high-specificity to avoid fighting the game's own stylesheet, but a z-index conflict with an existing UI element is still possible - inspect the element in browser dev tools to see what's actually stacking above or below it.


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
Recipe: Custom Weapon
Next
Recipe: Discord Integration