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: Discord Integration

Audience: Plugin authors · Prereqs: Events (concept)

Canonical source: server-game/src/rooms.js (packChat)

Posting room chat to a Discord webhook - server-side only, no browser code needed. Several bundled plugins already do something in this family (autoshopnotifications, playercountnotifications, and the built-in feedback command all post to Discord webhooks) - this recipe builds the same pattern from scratch for chat specifically.

The hook

packChat fires every time a chat packet is built, after censor.js filtering has already run, with the raw text and sender id:

// server-game/src/rooms.js - real code
packChat(output, text, id = 255, chatType = Comm.Chat.user) {
    plugins.emit('packChat', {this: this, output, text, id, chatType});
    // ...packs the actual network packet...
};

chatType distinguishes real user chat (Comm.Chat.user, value 0) from slash commands (cmd), blocked/censored messages (blocked), and whispers (whisper) - a Discord-relay plugin should filter to user only, otherwise every command a player types (and every message that got blocked by the censor) shows up in your Discord channel too.

The plugin

// plugins/discordchat/index.js
import log from 'puppylog';

export const PluginMeta = {
    identifier: "discordchat",
    name: 'Discord Chat Relay',
    author: 'you',
    version: '1.0.0',
    descriptionShort: 'Relays in-game chat to a Discord webhook.',
    descriptionLong: 'Posts every real user chat message to a configured Discord webhook.',
    legacyShellVersion: 598,
};

const WEBHOOK_URL = "https://discord.com/api/webhooks/your-webhook-here";

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

        if (plugins.type !== "game") {
            log.orange(`${PluginMeta.identifier} won't run on this server type.`);
            return;
        };

        this.plugins.on('game:packChat', this.onPackChat.bind(this));
    };

    onPackChat(data) {
        if (data.chatType !== 0) return; // Comm.Chat.user only - skip commands/blocked/whispers

        const [, player] = data.this.getPlayerClient(data.id);
        const username = player?.name || "Unknown";

        fetch(WEBHOOK_URL, {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify({ content: `**${username}**: ${data.text}` }),
        }).catch(err => log.red(`discordchat: webhook post failed: ${err.message}`));
    };
};

Uses the built-in fetch (available natively in Node 18+, which is already this project's minimum - see Requirements) rather than adding a dependency for something this simple.

Why .catch and not await

The webhook post isn't awaited - onPackChat returns immediately, and the actual HTTP request resolves (or fails) in the background. This is deliberate: packChat runs synchronously inside packet-building, on the hot path of every chat message sent to every player in the room - blocking that on an external HTTP round-trip would add real, user-visible latency to something that should be instant. The .catch just makes sure a failed request logs instead of becoming an unhandled promise rejection.

Rate limiting your own webhook

Discord itself rate-limits webhooks - a very active room could trigger Discord's own throttling if every single message is relayed instantly. If that becomes a problem, batch messages into an interval-based digest instead of firing one HTTP request per chat line, the same way the bundled playercountnotifications plugin posts periodic summaries on a timer rather than one webhook call per individual event.

What we validated

Loaded against a real (isolated, scratch) game server: the plugin registers its game:packChat listener cleanly with no errors, on both the main thread and inside a room worker. We did not send a real request to any Discord webhook as part of validating this recipe - doing so would require an actual webhook URL and would post a real message to a real external channel, which isn't something to do as a side effect of writing documentation. Triggering packChat itself also requires live chat activity in a real match, the same category of limitation as Killstreaks.

Common Issues

Every command a player types shows up in Discord too. You're not filtering chatType - see the check above.

Nothing posts, no error either. Check the webhook URL is actually valid and the log line from the .catch handler isn't appearing somewhere you're not looking (services/game logs are separate - this fires from the game server, not services).


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: UI Modification
Next
Recipe: Replacing Core Behaviour