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: Killstreaks

Audience: Plugin authors · Prereqs: Events (concept), Workers and State

Canonical source: src/shell/player.js (die, hit, changeModifiers), server-game/src/rooms.js (getPlayerClient, notify)

A complete, working plugin: consecutive kills without dying grant a temporary speed buff and a one-time notification, reset on death. This is the simplest realistic example of reacting to gameplay and changing a player's live stats - a good template for anything in the same shape (rewards, punishments, streak-gated unlocks).

The event, and a real timing gotcha

Player.die(firedId) fires game:onPlayerDeath with { player, firedId } (player is the victim, firedId is the killer's id, or null/absent for environmental deaths). This fires before the killer's own kill is scored - hit() calls this.die(firedPlayerId) first, then firedPlayer.scoreKill(this) immediately after. That means Player already has a streak field, incremented in scoreKill, but reading it inside an onPlayerDeath handler gets you the count before this kill - not including it.

Rather than relying on that ordering (fragile to depend on, and confusing to read), this recipe keeps its own counter. It's marginally more code, but it's obviously correct regardless of exactly when core code updates its own fields relative to the emit.

The plugin

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

export const PluginMeta = {
    identifier: "killstreaks",
    name: 'Killstreaks',
    author: 'you',
    version: '1.0.0',
    descriptionShort: 'Rewards consecutive kills with a temporary speed buff.',
    descriptionLong: 'Tracks a per-player kill streak within each room; grants an escalating speed buff at set thresholds, reset on death.',
    legacyShellVersion: 598,
};

const TIERS = [
    { at: 3, speedModifier: 1.15, text: "is on a streak!" },
    { at: 5, speedModifier: 1.30, text: "is dominating!" },
    { at: 8, speedModifier: 1.50, text: "is UNSTOPPABLE!" },
];

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;
        };

        // Map<playerId, streakCount> - one Map per room worker, see "Why this is room-scoped, and that's fine" below
        this.streaks = new Map();

        this.plugins.on('game:onPlayerDeath', this.onPlayerDeath.bind(this));
        this.plugins.on('game:disconnectClient', this.onDisconnect.bind(this));
    };

    onPlayerDeath(data) {
        const victim = data.player;
        const killerId = data.firedId;

        // the victim's own streak is over, regardless of who/what killed them
        this.streaks.set(victim.id, 0);

        // no killer (environment), or a suicide - nothing to reward
        if (killerId == null || killerId === victim.id) return;

        const [killerClient, killerPlayer] = victim.room.getPlayerClient(killerId);
        if (!killerClient || !killerPlayer) return; // killer already disconnected

        const streak = (this.streaks.get(killerId) || 0) + 1;
        this.streaks.set(killerId, streak);

        const tier = TIERS.find(t => t.at === streak);
        if (!tier) return; // only fires exactly on threshold kills, not every kill above it

        killerPlayer.changeModifiers({ speedModifier: tier.speedModifier });
        killerClient.notify(`${killerPlayer.name} ${tier.text}`, 4000);
    };

    onDisconnect(data) {
        this.streaks.delete(data.client?.player?.id);
    };
};

Why this is room-scoped, and that's fine

this.streaks lives on the plugin instance, which (per Workers and State) is a genuinely separate object per room worker - so this Map is naturally scoped to one match, which is exactly the right scope for a killstreak (nobody expects a streak to carry across matches, let alone across other rooms entirely). If you wanted streaks to persist across a player's whole session (not just one room), that's a different, harder problem - see Workers and State for the options.

Key APIs used

  • victim.room - every Player holds a direct reference to its room (this.room = this.client.room, set at construction), so you don't need to separately track "which room is this player in."
  • room.getPlayerClient(id) - resolves a numeric player id to [client, player], or [null, null] if that id isn't currently in the room (handles the disconnected-mid-flight case cleanly).
  • client.notify(text, timeoutTime) - sends a toast to exactly that one client. (room.notify(text, timeoutTime) - no target argument - broadcasts to the whole room instead; easy to reach for by mistake if you only skimmed the name.)
  • player.changeModifiers({...}) - merges fields into player.modifiers, read directly by the physics simulation every tick - the speed change takes effect immediately, authoritatively, server-side.

A caveat worth knowing

changeModifiers here only runs server-side. The affected player's own client-side prediction doesn't know their speed just changed until the next state sync corrects it - in practice this is a very brief, usually unnoticeable discrepancy (a fraction of a second at most, at the game's ~10Hz full sync rate), but if you're building something where that gap actually matters, you'd want a matching client-side change too, following Prediction and Authority.

Validated

This plugin was loaded against a real (isolated, scratch) services + game server pair while writing this page, and boots with no errors - confirming the folder structure, imports, and event names are all correct as written. Triggering the actual reward requires live gameplay (two real players, one killing the other enough times) which is outside what can be automated in a docs validation pass - if you build this yourself, watch the game server's console and the killed/killer's in-game notification to confirm the tiers fire as expected.


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
Next
Recipe: New Pickup Item