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: Persistent Plugin Storage

Audience: Plugin authors · Prereqs: Anatomy

Canonical source: plugins_default/autoshopnotifications/index.js, plugins_default/playercountnotifications/index.js

A plugin's own on-disk config/state that survives a server restart - there's no built-in key-value store or ORM for this, it's just node:fs reading and writing JSON next to your plugin's own code. Two bundled plugins already do exactly this in production; this recipe is that same real pattern, extracted and explained.

Why not the database?

server-services/store/LegacyShellData.db (see The Database) is services' database, reachable only from services-side plugin code, and its schema is meant for accounts/items/maps/sessions - not ad-hoc plugin state. A game or client server plugin (where most plugin logic actually lives) has no direct DB access at all. For "remember this one JSON blob between restarts," a file in your own plugin folder is simpler, requires no schema, and works identically on every server role.

The pattern

// plugins/mystats/index.js - the real pattern, from plugins_default/autoshopnotifications/index.js
import fs from 'node:fs';
import path from 'node:path';
import log from 'puppylog';

export const PluginMeta = {
    identifier: "mystats",
    name: 'My Stats',
    author: 'you',
    version: '1.0.0',
    descriptionShort: 'Tracks something across restarts.',
    descriptionLong: 'A minimal example of a plugin persisting its own JSON state to disk.',
    legacyShellVersion: 598,
};

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

        this.storeFolder = path.join(this.thisDir, 'store');
        if (!fs.existsSync(this.storeFolder)) {
            fs.mkdirSync(this.storeFolder, { recursive: true });
        };

        var config = this.getConfig();
        log.beige(`mystats: loaded, seenTotal so far is ${config.seenTotal}`);

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

    getConfig() {
        const configPath = path.join(this.storeFolder, 'mystats.json');
        if (!fs.existsSync(configPath)) {
            fs.writeFileSync(configPath, JSON.stringify({ seenTotal: 0 }, null, 4));
        };
        return JSON.parse(fs.readFileSync(configPath, 'utf8'));
    };

    saveConfig(config) {
        const configPath = path.join(this.storeFolder, 'mystats.json');
        fs.writeFileSync(configPath, JSON.stringify(config, null, 4));
    };

    onPlayerDeath(data) {
        var config = this.getConfig();
        config.seenTotal++;
        this.saveConfig(config);
    };
};

This is a direct simplification of the real getConfig/saveConfig pair in plugins_default/autoshopnotifications/index.js:159-176 (and duplicated almost verbatim in plugins_default/playercountnotifications/index.js:186-201) - two independent, currently-shipping plugins converged on the same read-if-missing-else-parse, write-whole-file-back approach, which is a reasonable sign this is the idiomatic way to do it in this codebase rather than something more elaborate.

Why thisDir/store/, not thisDir directly

Keeping persisted data in a store/ subfolder (created with { recursive: true } so it's a no-op if it already exists) separates your plugin's actual code from its generated state - useful if the plugin folder is a git repository that gets auto-pulled on every boot (see Lifecycle): a .gitignore entry for store/ keeps a git pull from ever touching (or conflicting with) your saved state. This mirrors the top-level store/ folder pattern used by the server roles themselves (server-services/store/, server-game/store/, etc.) - see Repo Layout.

Read-modify-write, not an in-memory cache

Notice getConfig() is called fresh every time, re-reading and re-parsing the file rather than keeping a single in-memory object updated over the plugin's lifetime. For infrequent writes (a notification-tracking plugin firing a few times a day) this is simple and correct - it can't drift from what's actually on disk. If you're writing on every single game tick or similar hot path, that's a sign this pattern isn't the right fit (disk I/O at 60Hz would be a real problem) - batch writes on a timer instead, or keep an in-memory copy and only flush periodically.

What we validated

Loaded this exact plugin against a real (isolated, scratch) game server: it loads cleanly with no errors, on both the main thread and the spare room worker, and genuinely creates plugins/mystats/store/mystats.json on disk with {"seenTotal": 0} the first time it boots - confirmed by inspecting the file after startup, not just reading the log.

Common Issues

My config file gets reset every restart. Check fs.existsSync(configPath) is actually finding your file - a relative path used instead of path.join(this.thisDir, ...) resolves against the server process's current working directory, not your plugin's folder, and silently "finds" nothing every time.

Two server processes (e.g. game and a spare room worker) are stepping on each other's writes. This simple read-modify-write pattern has no locking - it's fine for occasional writes from a single logical writer, but if multiple processes (see Workers and State - every room worker runs its own independent copy of your plugin) write to the same file concurrently, the last write wins and can lose data from the other. Keep truly shared, frequently-written state in a database instead, or have only one process (e.g. only the game server's main thread, gated with if (plugins.type === "game")) own the writes.


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: Replacing Core Behaviour
Next
Recipe: Rewarding Players with Currency