CityRP Documentation Repository

Plugins

Last updated

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:

  1. Plugin phase — every plugin's sv_/cl_/sh_init.lua runs. A plugin cannot see its own sub-resources yet, but every plugin has initialised by the time any sub-resource loads.
  2. Entity/weapon/effect phase — immediately after. Each loaded plugin's entities/ and weapons/ classes register, and its effects/ are sent (server) or registered (client).
  3. Item phase — after core/items/*, so plugin items can inherit from bases registered there. cityrp.item.stored's uponLoad sweep runs after this and covers plugin items automatically. Every item registered here gets ITEM.PLUGIN set to the owning plugin's table automatically (nil for core/items), so a plugin item calls back into its own plugin as self.PLUGIN.Something(...) rather than looking the key up.
  4. Derma phase — after core/derma, same prefix rules (server sends every file, includes only sh_; 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_servers is checked before servers, not_maps before maps.
  • Both axes apply — a plugin declaring both servers and maps must satisfy both.
  • All four absent is today's behaviour: available everywhere. Every plugin without a .meta.lua, and every .meta.lua that 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.