Plugins
Last updated
May be stale
This page was last updated ago, on 17 September 2026. Check what it says against the code before relying on it.
Plugins live in gamemode/core/plugins/ and are the unit CityRP features are built in — "new features should be plugins when possible" (CONTRIBUTING.md). There are two formats. Both are discovered and ordered by cityrp.plugin.Resolve (gamemode/core/libraries/sh_plugin.lua) and executed by the loop in gamemode/sh_init.lua.
Choosing a format
| Folder plugin | File plugin | |
|---|---|---|
| Layout | core/plugins/<key>/sv_init.lua, cl_init.lua, sh_init.lua |
core/plugins/<scope>_<key>.lua |
| Realms | Any combination | Exactly one file, on the realms its scope prefix implies |
| Extra files | Yes — include them yourself from sv_init/cl_init |
No |
| Shared state across realms | Via the PLUGIN_SHARED handshake |
Not applicable — there is only one file |
Use a file plugin when the whole feature is one file. Use a folder plugin when it needs more than one file, genuinely different code per realm, or sub-resources such as entities, weapons, effects, items, or derma panels.
Do not convert existing folder plugins wholesale. Convert one opportunistically when you are already editing it and it qualifies.
File plugins
A file plugin is a single .lua file sitting directly in core/plugins/, named <scope>_<key>.lua:
| File | Server does | Client does |
|---|---|---|
sv_<key>.lua |
include() |
nothing — the file is never sent |
cl_<key>.lua |
AddCSLuaFile() |
include() |
sh_<key>.lua |
AddCSLuaFile() + include() |
include() |
--- Kicks players who idle past the AFK limit.
--- @copyright 2015-2026 Limelight Gaming
--- @release CityRP 2
--- @author You
--- @namespace AutoKick
--- @state server
local PLUGIN = {}
PLUGIN.name = "Auto Kick"
PLUGIN.author = "You"
hook.Add("Think", "AutoKickThink", function() end)
cityrp.plugin.register(PLUGIN)
core/plugins/sv_auto_restart.lua is a real example.
sh_ is not the folder format's sh_init.lua
This is the one place the two formats genuinely differ, and getting it wrong is easy.
The loader never includes a folder plugin's sh_init.lua. It only AddCSLuaFiles it; each folder plugin includes its own shared file and hands its table across through the PLUGIN_SHARED global.
A file plugin has no second file to hand a table to, so sh_<key>.lua simply means "included on both realms". Never read or write PLUGIN_SHARED from a file plugin. Folder plugins assign it as a global and never clear it, so a file plugin that reads it picks up whichever folder plugin happened to run last. Use SERVER/CLIENT guards inside the file instead — or, if the realms need to diverge more than that reads cleanly, use a folder plugin.
Folder plugins
core/plugins/<key>/
sv_init.lua included on the server
cl_init.lua sent to the client, included there
sh_init.lua sent to the client; included by sv_init/cl_init, never by the loader
The PLUGIN global
The loader owns the plugin table. Before including any of a plugin's files it seeds PLUGIN — a
plain table, no metatable — and clears it afterwards. A plugin does not build its own table and does
not register it; the loader registers whatever it seeded once the includes finish. Seeded fields:
| Field | |
|---|---|
key |
The plugin key. Use this over a repeated string literal when a plugin needs to name itself. |
name |
Display name, defaulting to the key. Set it if you want something friendlier. |
logger |
The plugin's logger, at plugin.<key> — available during load, not just after. |
AddHook |
See Hooks below. |
Dependencies |
The plugins this one declared in its .meta.lua, keyed by plugin key. See Declared dependencies. |
-- sv_init.lua, as core/plugins/flashlight/ now does
local PLUGIN = PLUGIN
include("sh_init.lua")
function PLUGIN.postPlayerSpawn(ply, light) end
hook.Add("PostPlayerSpawn", "PostPlayerSpawnflashlight", PLUGIN.postPlayerSpawn)
-- sh_init.lua
local PLUGIN = PLUGIN
PLUGIN.name = "Flashlight"
Localise it — local PLUGIN = PLUGIN — rather than reading the bare global. The loader clears
the global once your includes finish, so anything deferred (a hook body, a timer, a net.Receive)
that resolved PLUGIN at call time instead of capturing it would find nil. The one file that has
nothing to say about its own table can skip the local entirely, as flashlight/cl_init.lua does.
A folder plugin's sh_init.lua is included by its own sv_init/cl_init, one level deeper than
the loader, and sees the same seeded table — which is what the old PLUGIN_SHARED dance emulated by
hand. Any file beyond those three is the plugin's own responsibility to include/AddCSLuaFile,
and sees the same table again.
The old shapes, during the transition
Most plugins have not migrated yet, and run unchanged. Their local PLUGIN = {} simply shadows the
seeded global, and their own PLUGIN_SHARED = PLUGIN keeps their sub-files consistent, so the two
styles coexist:
-- the legacy dance, still valid
local PLUGIN = {}
PLUGIN_SHARED = PLUGIN
include("sh_init.lua")
-- ...
cityrp.plugin.register(PLUGIN)
An explicit cityrp.plugin.register(PLUGIN) still works and suppresses the loader's automatic
registration, so nothing registers twice. Do not write new plugins this way — PLUGIN_SHARED is a
leaking global that holds whichever plugin loaded last, which is the bug the seeded global exists to
remove.
Hooks
PLUGIN:AddHook(event[, func]) registers a GMod hook owned by the plugin, identified as
cityrp.plugin.<key>.<event>:
PLUGIN:AddHook("InitPostEntity", function(self) end) -- a function, called as func(plugin, ...)
PLUGIN:AddHook("InitPostEntity", "SetUp") -- names a method, resolved when it fires
PLUGIN:AddHook("InitPostEntity") -- same, defaulting to the event's name
One handler per event per plugin: re-registering the same event replaces the previous handler rather
than stacking a second, which is what stops a re-included file doubling up (footgun #7). A named
method that does not exist logs an error and removes the hook the first time it fires — an
unguarded miss on a Think-class event would otherwise error every frame.
Sub-resources: entities, weapons, effects, items, derma
A folder plugin can own entities, weapons, scripted effects, items, and derma panels directly, so "plugin disabled/absent ⇒ its content doesn't load" is automatic instead of per-file gating. File plugins never get sub-resources.
core/plugins/<key>/
entities/<class>/ or entities/<class>.lua (ENT, engine-style layout)
weapons/<class>/ or weapons/<class>.lua (SWEP, engine-style layout)
effects/<name>/ or effects/<name>.lua (EFFECT, engine-style layout)
items/*.lua (same conventions as core/items)
derma/{sv,sh,cl}_*.lua (same conventions as core/derma)
entities//weapons/ follow the engine's own folder convention, matched exactly so migration is a pure file move: each realm includes one entry file — init.lua on the server, cl_init.lua on the client, falling back to shared.lua — and shared.lua is otherwise only included by those files themselves, as existing init.lua files already do. The loader (cityrp.plugin.LoadSubResourceEntities in sh_plugin.lua) also mirrors what the engine pre-sets on the global before including: SWEP.Primary/SWEP.Secondary as empty tables, SWEP.Base = "weapon_base", and Folder on both kinds — so weapon files that write SWEP.Primary.Damage = ... without creating the table keep working unchanged. It then calls scripted_ents.Register/weapons.Register and clears the global — the same shape as core/plugins/animations/animationsapi/mocap_controller.lua's manual registration. Class names are lowercased, like plugin keys, because the client's download cache lowercases paths.
effects/ follows the engine's effect layout: a single <name>.lua, or a <name>/ folder whose only entry file is init.lua. Effects exist only clientside, so the server just AddCSLuaFiles each entry file and registers nothing, and the client includes it with EFFECT seeded (EFFECT.Folder set, the owning plugin readable as PLUGIN), calls effects.Register under the lowercased name, and clears the global. As with the engine, nothing else in a folder effect is sent: an init.lua that includes a sibling file has to AddCSLuaFile it itself. Trigger a plugin effect with util.Effect by that name from either realm, as with any other effect.
One deliberate superset of engine semantics: the loader always AddCSLuaFiles shared.lua and cl_init.lua itself. Under the engine those files are only sent because init.lua manually sends them (entity folders are not on the engine's auto-send list), which is the classic forgotten-boilerplate footgun — the entity works on a listen server and breaks for connecting clients. In a plugin entity folder that boilerplate is therefore optional: existing AddCSLuaFile lines in migrated files are harmless duplicates, and new files can omit them. The include side is unchanged — the client still only includes its one entry file.
Loading happens in this order, once per map load, in plugin load order:
- Plugin phase — every plugin's
sv_/cl_/sh_init.luaruns. A plugin cannot see its own sub-resources yet, but every plugin has initialised by the time any sub-resource loads. - Entity/weapon/effect phase — immediately after. Each loaded plugin's
entities/andweapons/classes register, and itseffects/are sent (server) or registered (client). - Item phase — after
core/items/*, so plugin items can inherit from bases registered there.cityrp.item.stored'suponLoadsweep runs after this and covers plugin items automatically. Every item registered here getsITEM.PLUGINset to the owning plugin's table automatically (nilforcore/items), so a plugin item calls back into its own plugin asself.PLUGIN.Something(...)rather than looking the key up. - Derma phase — after
core/derma, same prefix rules (server sends every file, includes onlysh_; client includes everything).
Only a loaded plugin's sub-resources are loaded — a plugin that failed to resolve (dependencies and load order) or is disabled contributes nothing here, silently.
Class name collisions
Two plugins declaring the same class is logged as an ERROR on the server at load, naming both claimants. The gamemode's own entities//weapons/ tree is a separate case: the engine scans it after gamemode Lua finishes, so a same-named class there silently overwrites the plugin's registration — the loader catches that with an InitPostEntity sweep that verifies every plugin-registered class survived, and logs an ERROR naming the overwritten class. Neither is ever fatal: GMod's registries have last-registration-wins semantics regardless of what the loader does, so treat the logs as the guard rail, not a safety net.
Effects have no serverside registry for that sweep to read, so the server checks them at load instead, and logs an ERROR for either clash: a second plugin claiming the same effect name (naming both), or a same-named effect the engine loads itself, from the entities/effects/ tree of this gamemode or a gamemode it derives from (such as sandbox), or from an addon's lua/effects/. The engine loads those after gamemode Lua, so its copy replaces the plugin's. A folder effect with no init.lua logs a WARNING and registers nothing.
Migration status
Only weapons/cityrp_breathalyser/ has been migrated, from the gamemode-level entities/weapons/ tree into core/plugins/breathalyser/weapons/, as the pilot for this system. Migrating other plugin-dependent entities/items is follow-up work, done opportunistically per plugin — do not bulk-migrate.
Registration
New plugins register nothing. The loader seeds PLUGIN and registers it once your includes
finish — see The PLUGIN global.
The legacy form, local PLUGIN = {} … cityrp.plugin.register(PLUGIN), still works and is what
most plugins currently do; an explicit register call suppresses the automatic one. The old class
form (PLUGIN:New(name, author) … PLUGIN:Register(), from sh_plugincore.lua) is gone — the
file is deleted. Its enable/disable methods had no callers and enablement belongs to the loader
(.meta.lua disabled, availability conditions, dependency status); its 13 data- and map-file
helpers had no callers either and have no successor. AddHook survived, and is now seeded onto
every plugin.
cityrp.plugin.stored is keyed by the plugin key, the same identity .meta.lua and load order use — so a lookup is cityrp.plugin.get("area"). cityrp.plugin.get still accepts the display name (cityrp.plugin.get("Area Manager")) and resolves it through cityrp.plugin.names, but warns once per name: the two identities are not derivable from one another, so nothing can bridge them automatically. Hundreds of call sites still pass display names; they work, and converting one is worth doing when you are already editing that code. Both forms cost the same — the key is simply the identity everything else already uses.
Registration also stamps the resolved key onto the plugin as PLUGIN.key, creates a per-plugin logger at plugin.<key>, and exposes it as PLUGIN.logger. Use PLUGIN.key rather than repeating the key as a literal when a plugin needs to look itself up.
Keys
A plugin's key is its identity for everything the loader does. It is the folder name, or a file plugin's name with the scope prefix and .lua stripped:
| On disk | Key |
|---|---|
core/plugins/stamina/ |
stamina |
core/plugins/sv_auto_restart.lua |
auto_restart |
core/plugins/sh_billboards.lua |
billboards |
Keys are lowercased, so file names should be all lowercase. Folders and files share one key space — a folder and a file cannot both claim the same key.
*.meta.lua holds a plugin's dependency metadata and is never loaded as a plugin.
Disabling a plugin
A plugin declares itself off in its own .meta.lua:
return {
-- Superseded by the admin plugin; kept for reference.
disabled = true,
}
disabled is a boolean expression, not just a literal — licenseplates/.meta.lua computes it from
cityrp.configuration["Plate Plugin"], the same config other code already reads that flag from. It
lives in the same table as dependencies and load order and
availability conditions — a plugin can declare all three. A disabled
plugin is skipped on both realms, contributes no sub-resources, and its client files are never
sent; any dependent sees it as disabled, not not installed.
The why belongs as a comment above the field, not a separate disabledReason key.
There is no loader-side override list. For "off on some servers or maps," use
availability conditions instead — a file-scope
if LLServer == "build" then return end is no longer a supported way to not-exist.
Key conflicts
Two things claiming one key is a conflict. It is logged as an ERROR on the server and is never fatal — the highest-precedence candidate loads and the rest are ignored:
| Precedence | Candidate |
|---|---|
| 1 (highest) | folder |
| 2 | sh_<key>.lua |
| 3 | sv_<key>.lua |
| 4 (lowest) | cl_<key>.lua |
The trap worth naming: sv_foo.lua + cl_foo.lua is a conflict, not a two-realm plugin. It is the shape of the folder format, so it is the natural mistake, and its consequence is that the client half silently never loads. Use a folder plugin for a server/client pair.
The losing candidate is never AddCSLuaFiled, so the client only ever receives the winner.
A .lua file in core/plugins/ with no recognised scope prefix logs a WARNING and is skipped. Non-Lua files are ignored silently.
Dependencies and load order
Plugins load in alphabetical order by key, folders and files interleaved, unless something says otherwise. A plugin says otherwise in an optional .meta.lua beside it:
| Format | Metadata file |
|---|---|
| Folder plugin | core/plugins/<key>/.meta.lua |
| File plugin | core/plugins/<key>.meta.lua |
It returns a plain table and must have no side effects — it is read before any plugin code runs, on both realms. The same table also carries availability conditions:
return {
dependencies = {"police"},
optional_dependencies = {"banking"},
after = {"excusenlr"},
before = {},
not_servers = {"build"}
}
Every dependency/ordering value is a plugin key — the folder name, or a file plugin's name without its scope prefix. Not PLUGIN.name. Matching is exact and keys are lowercase, so write them lowercase.
A plugin with no .meta.lua declares nothing and keeps its alphabetical slot. That is the normal case and is silent.
Depending versus ordering
These answer two different questions, and picking the wrong one is the easy mistake:
| Field | Means | If the target is absent |
|---|---|---|
dependencies |
I cannot work without this | This plugin does not load. ERROR |
optional_dependencies |
I use this when it is here | Loads anyway. WARNING, server only |
after |
I must run later than this | Loads anyway, no message |
before |
I must run earlier than this | Loads anyway, no message |
after and before are ordering only. They never fail a plugin and never claim it needs anything, so use them when you only care about sequence. core/plugins/networking/.meta.lua is after = {"*"} — the plugin used to be named zz_networking purely to sort last; the wildcard is what that name was really trying to say, and the folder no longer needs the trick.
"*" means every other plugin: after = {"*"} is "load last", before = {"*"} is "load first". It is only meaningful in those two fields; a "*" in either dependency list is rejected with a WARNING, since requiring every plugin would make any single absence fatal.
What the loader does with it
Order is a topological sort, tie-broken alphabetically: of the plugins whose dependencies are already satisfied, the alphabetically first goes next. A plugin therefore keeps its usual slot unless something genuinely has to come before it.
| Situation | Result |
|---|---|
| Required dependency missing or disabled | Dependent does not load. ERROR |
| A plugin that something requires fails | That dependent does not load either, transitively. ERROR |
| Required dependency cycle | Every plugin in the cycle fails, and the ERROR names the path |
| Optional dependency missing | Loads without it. WARNING, server only |
| Any soft rule that would create a cycle | That one ordering rule is dropped, never the plugin |
Nothing here is fatal to the server or to any other plugin. A failed plugin's client files are still sent, because the client resolves the same graph independently and may reach a different answer.
The realm trap
A dependency must exist on every realm its dependent loads on. There is no realm tagging: a plugin that is not on this realm is simply missing here.
32 of the 116 plugins are server-only — a folder with neither a cl_init.lua nor an sh_init.lua, or an sv_ file plugin — so they do not exist clientside at all. A plugin that loads on both realms declaring dependencies = {"clans"} loads perfectly on the server and then fails on every client, because clans is server-only. Depend on a server-only plugin only from another server-only plugin; otherwise use optional_dependencies, or guard the call site instead.
Reading another plugin
Declaring a dependency fixes the load order; it does not make a file-scope lookup good practice. core/plugins/hud/cl_init.lua calls cityrp.plugin.get("Excuse NLR") at file scope and works only because excusenlr sorts before hud. Do the lookup inside a hook or at run time instead, and guard it:
hook.Add("InitPostEntity", "MyPluginSetup", function()
local other = cityrp.plugin.get("excusenlr")
if not other then return end
end)
Pass the plugin key, the same value the metadata uses. A display name still resolves, but warns.
Items load after every plugin, so item files may gate on a plugin at file scope — core/items/ammunition/sh_ammo_pistol.lua opens with if not cityrp.plugin.get("generic") then return end.
Declared dependencies
If the plugin you want is already in your .meta.lua, do not look it up at all — the loader has
resolved it for you, on PLUGIN.Dependencies, keyed by the same key the metadata uses:
-- a plugin whose .meta.lua declares dependencies = {"generic"}
PLUGIN.Dependencies.generic.PassiveDamageWeapons["cityrp_pickaxe"] = true
A required dependency is never nil. If it were missing your plugin would not have loaded, so
there is nothing to guard — which is the point, and the reason to prefer this over
IsAvailable + get. core/plugins/caw/ used to check cityrp.plugin.get("Clans") at runtime
despite declaring dependencies = {"clans"}; that check could never fail and is gone.
This is also what lets a plugin contribute into a dependency rather than the dependency having to
enumerate its contributors — a plugin owning a weapon can register it with generic itself, instead
of generic hardcoding a list of classes owned by plugins it knows nothing about.
An optional dependency may be nil, and must be guarded — that is what makes it optional:
-- stamina/.meta.lua declares optional_dependencies = {"elf"}
local elf = PLUGIN.Dependencies.elf
if elf then ... end
One timing caveat for optional dependencies specifically. Load order puts a required dependency
strictly before its dependent, so it is resolved before your first line runs. An optional one
normally is too, but its ordering edge is a soft one, and the loader drops it — with a WARNING —
when the opposite order is already required. In that case the entry appears part-way through the
load rather than at your file scope. It is always there by CityRP.Core.PostPluginLoad. If you read
an optional dependency at file scope, read it again where you use it.
PLUGIN.Dependencies is always a table, never nil, even for a plugin with no .meta.lua at all.
Guarding a cross-plugin call
When you only need to know whether a plugin is there, not to call into its table, use cityrp.plugin.IsAvailable(key):
if cityrp.plugin.IsAvailable("banking") then
-- safe to call into the banking plugin here
end
Any plugin can be removed from the repo at any time, and nothing stops another plugin's code from assuming it still exists — so an unguarded cross-plugin reference is a live bug, not a hypothetical one. There is deliberately no structural safety net: nothing proxies cross-plugin calls into silent no-ops, because that would turn "plugin removed" into a quietly degraded feature instead of a loud, findable one. Call sites guard themselves.
IsAvailable is not the same as cityrp.plugin.get(key) ~= nil, and the difference bites. It reads the loader's own bookkeeping rather than cityrp.plugin.stored, so it is true for the ~27 plugins that load without ever calling register, and for xp-bar, which returns early without registering when MESKA is absent. get would call all of those missing.
It is also realm-accurate in both directions. A server-only plugin is never discovered clientside, so IsAvailable is correctly false there — the query-side counterpart of the realm trap above. A plugin with no realm files of its own but with sub-resources does materialise clientside and reads true.
Every reason a plugin might be absent — never installed, removed, disabled = true, excluded by servers/maps, a missing required dependency, a required dependency cycle — returns the same false, because from a caller's side they are the same thing. When the difference matters, cityrp.plugin.GetDependencyStatus(key) returns the outcome (status, reason, missing, missingOptional, cycle, order) or nil if nothing by that key was discovered here. That one is for diagnostics and error messages — do not guard with it; its shape is an implementation detail and may gain fields.
Availability conditions
A plugin can declare in its .meta.lua which servers and maps it is allowed to exist on. Four optional keys, all lists of lowercase strings, matched exactly against LLServer and string.lower(game.GetMap()):
return {
servers = {"v2d", "usv2d"}, -- allowlist of server codes; absent = every server
not_servers = {"build"}, -- blocklist; wins over servers
maps = {"rp_evocity_v33x"}, -- allowlist of map names; absent = every map
not_maps = {"rp_florida_v2"} -- blocklist; wins over maps
}
They live in the same table as dependencies and load order — a plugin can declare both.
Rules:
- Within an axis (server or map), the blocklist beats the allowlist:
not_serversis checked beforeservers,not_mapsbeforemaps. - Both axes apply — a plugin declaring both
serversandmapsmust satisfy both. - All four absent is today's behaviour: available everywhere. Every plugin without a
.meta.lua, and every.meta.luathat only declares dependencies, is unaffected.
A plugin excluded this way is treated exactly like a plugin declaring disabled = true: it never enters the discovered set, so it contributes no sub-resources and is never AddCSLuaFile'd, and any dependent sees it as disabled — not not installed — with the usual cascading ERROR. A DEBUG log line names the reason, e.g. Skipping 'flashlight': excluded on server 'build'., so a gated plugin does not read as hand-disabled when you go looking for it.
Server codes, not server names
Match against LLServer (v2d, usv2d, v4b1_fl2, southside, build, event, beta, staging, …) — the same value the realm trap uses — not what the team calls a server out loud. build and event are Live, not Development; Live versus Development is not the axis this gates on.
The trap: "beta2" is a server name, not a server code. Both beta servers report LLServer == "beta"; only LLServerChatID tells them apart. servers = {"beta2"} silently never matches anything. The server logs a WARNING for a servers/not_servers entry that names no file under core/server/ — server only, since the client is only ever sent the one file matching its own LLServer and has no way to see the rest of the list.
Map names are not validated against a known list — there isn't one to check against.
Migration
An in-file early return is no longer a supported way to not-exist. if LLServer == "build" then return end at the top of a plugin's sv_init.lua/sh_init.lua/cl_init.lua compiles and works for the code in that one file, but it does nothing about the plugin's sub-resources or its AddCSLuaFiles — replace it with not_servers = {"build"} in a sibling .meta.lua instead.
Troubleshooting
The loader logs to the plugins namespace at DEBUG, which is below the default level. On the server:
cityrp_log_set plugins DEBUG
Levels persist server-side and are re-read at gamemode init, and plugins load at map load — so set the level, then restart or change level, then read the output.
The same command on a client does not persist, so client-side load-time DEBUG output is effectively unobservable. Verify the client by effect instead:
lua_run_cl print(cityrp.plugin.get("auto_restart") ~= nil)
lua_run_cl PrintTable(select(1, file.Find(GMFOLDER .. "/gamemode/core/plugins/*", "LUA")))
The second line lists the file plugins the client actually received, which is the quickest way to confirm an AddCSLuaFile did or did not happen.
Conflict ERRORs and unrecognised-file WARNINGs are logged on the server only — the client cannot see a losing candidate, and both levels would otherwise print into every player's console.
Dependency failures are the exception: they are logged on whichever realm they happen on, because the two realms resolve independently and the server cannot tell that a client failed. A dependency ERROR appearing only in player consoles is the realm trap, and the message says so. A missing optional dependency is not a failure and is reported on the server only — the server sees the whole disk, so clientside that message would mostly mean "this one is server-only", which is the case optional dependencies exist for.