Skip to content

The Legacy Plugin System

How the original achieved modularity, why it worked socially, and why it cannot be copied.

Sources: libraries/sh_plugin.lua (98 lines), gamemode/plugins/*.


1. Loading

GM:LoadPlugins() scans gamemode/plugins/*/. For each directory:

  • Server: include sv_init.lua, AddCSLuaFile cl_init.lua
  • Client: include cl_init.lua
  • sh_init.lua is included by the plugin itself via includecs

A global PLUGIN = {} is created before each include. A file registers itself by setting PLUGIN.Name — if it does not, the directory is skipped silently. The populated table is stored under the directory name.

cider_reload_plugins (superadmin) hot-reloads everything. GM:GetPlugin(id) does exact match then fuzzy name match — which is how items and jobs query for optional systems at load time (if GM:GetPlugin("hunger") then …), and a fuzzy lookup that silently resolves to the wrong plugin as the set grows (D-08).


2. Dispatch — the monkey patch

This is the mechanism, and it is the thing that must never be reproduced:

if (not hook.oCall) then hook.oCall = hook.Call end
function hook.Call(name, gm, ...)
    for _, plugin in pairs(stored) do
        if type(plugin[name]) == "function" then
            success, a, b, ... = pcall(plugin[name], plugin, ...)
            if not success then ErrorNoHalt(...)
            elseif a ~= nil then return a, b, ...     -- ← short-circuits everything
            end
        end
    end
    return hook.oCall(name, gm, ...)
end

hook.Call itself is replaced. Every hook in the game — the gamemode's, Sandbox's, the engine's — is routed through a loop over every plugin table.

The comment in the source is honest about the intent: "This has the added bonus of making plugin hooks immortal like gamemode ones." Plugins get first refusal on every event in the game without registering for anything.

Why it is dangerous

Property Consequence
Iteration order is pairs() over a hash table Plugin execution order is undefined and can change between runs
Any non-nil return short-circuits A plugin that accidentally returns a value silently suppresses the gamemode's own handler
No registration You cannot tell which plugins handle a hook without reading all of them
Every hook pays the scan Cost proportional to plugin count, on every event in the game
pcall swallows errors A broken plugin degrades instead of crashing — good — but fails invisibly

The short-circuit is the worst of these. There is no way to express "I want to observe this" versus "I want to override this"; returning anything at all is an override.

Design note: the new event bus makes the three intents explicit — observe, modify, cancel — with declared ordering and no global mutation. Architecture/04_EVENT_BUS.md.

What it got right

Two things, and they are worth keeping:

  1. Failure isolation. A broken plugin ErrorNoHalts and the server keeps running. The new design keeps this: a module that throws during an event handler is logged and isolated, not fatal.
  2. Hot reload. cider_reload_plugins in production is why the original could be iterated on quickly. S&box's hotload gives this for free.

3. The 16 plugins

Sizes are server-side line counts.

Substantial

Plugin Lines Role
vehicles 429 sv / 382 sh / 250 cl MakeVehicle, SpawnCar, SellCar, PickupCar, ManufactureCar, entry/exit, HUD, CalcView. Backs items/base/vehicle.lua entirely.
detail 449 sv Map-specific detail props and entities, placed on InitPostEntity
officials 400 sv Police and government policy. PlayerCanArrest/Unarrest/Stun/KnockOut/WakeUp/Warrant/Unwarrant/Demote/ChangeLaws, PlayerWarrantExpired, radio recipients, contraband destruction rewards, SayRequest / SayBroadcast
doors 241 sv Per-map door metadata; LoadDoors, LoadData, SaveData, EntityNameSet/MasterSet/Sealed/OwnerSet, PlayerCanOwnDoor, PlayerCanJamDoor
cleanmap 174 sv Strips unwanted map entities on InitPostEntity
spawnpoints 108 sv Per-map team spawns, GLON file, PostPlayerSpawn

Small

Plugin Lines Role
prisonpoints 94 sv Per-map jail points, PlayerArrested
packaging 91 sv Crates as containers; CrateTime, PlayerCanUseContainer, PlayerUpdateContainerContents
hunger 77 sv The hunger need (08)
stamina 66 sv The stamina need
headbob 42 cl CalcView bob
flashlight 34 sv Battery-limited flashlight
typing 33 sv "Is typing" indicator; blocks chat while dying
savefragsdeaths 21 sv Frags/deaths across PlayerInitialized / PlayerDisconnected
anonymous 12 cl Name hiding
crossserverchat (legacy/Applejack fork only) Cross-server chat relay

What the split tells us

The plugin boundary is drawn almost exactly where it should be:

  • Policy is a plugin (officials) while mechanism is core. Remove it and arrest still exists; nobody is authorised to use it.
  • Needs are plugins (hunger, stamina) and the game is coherent without them — the Chef job simply does not appear.
  • Map-specific data is a plugin (doors, spawnpoints, prisonpoints, detail, cleanmap), which is why the framework runs on any map.
  • Cosmetics are plugins (headbob, anonymous, typing).

That is a genuinely good modular decomposition arrived at pragmatically, and the new module boundaries should follow it closely. What fails is not the decomposition — it is the mechanism underneath it.


4. Client extension surface

Beyond hooks, the client exposes named extension points (09): DrawBottomBars, DrawTopText, AdjustESPLines, OpenChatBox, CloseChatBox.

Three plugins draw bottom bars (hunger, stamina, flashlight) and they compose without knowing about each other. This is the correct pattern for UI extension and it survives as a registration API rather than a hook.


5. Why this became ADR-0002

The existing documentation committed to "core hosts plugins only; all gameplay lives in plugins" with a full discovery / manifest / dependency-resolution lifecycle.

Reading the legacy system carefully argues against building that machinery first:

  • The original's modularity came from where the boundaries were drawn, not from the loader. The loader is 98 lines and mostly harmful.
  • Load-order coupling (D-08) arose precisely because the plugin system was a separate lifecycle that items and jobs had to interrogate at load time.
  • S&box already provides hotload and the Libraries system for third-party distribution, so a bespoke loader would be reimplementing engine features.

The conclusion: keep the boundaries, keep failure isolation, keep hot reload, and drop the loader. Modules are ordinary assemblies with declared dependencies and a deterministic lifecycle. See ADR-0002.


6. What survives

Survives: the plugin boundaries (policy, needs, map data, cosmetics as separate modules); failure isolation; hot reload; optional systems degrading gracefully rather than erroring; named client UI extension points; per-map data ownership.

Does not survive: monkey-patching hook.Call; registration by assigning a global; undefined execution order; truthy-return-as-override; fuzzy name lookup; load-order dependencies between plugins, items and jobs.