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:
includesv_init.lua,AddCSLuaFilecl_init.lua - Client:
includecl_init.lua sh_init.luais included by the plugin itself viaincludecs
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:
- 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. - Hot reload.
cider_reload_pluginsin 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.