CityRP Documentation Repository

Coding Standards (CityRP)

Last updated

This is the CityRP-specific coding standards gate for humans and agents. General Limelight process and architecture still live in the standards repo. If existing code conflicts with this page, this page wins for new and refactored work — nearby legacy is not permission to copy a forbidden pattern.

Layer Role
.cursor/rules/agent-musts.mdc Always-injected agent summary
This topic Authoritative CityRP checklist + reject criteria
Modern APIs Per-category mandatory vs forbidden APIs
LuaCATS Annotations Mandatory function docs
AGENTS.md Silent-failure footguns unique to this codebase
CI (lint-diff + standards-diff) Machine enforcement on changed lines

Non-negotiables

  1. Modern APIs only for new/refactored code — see modern-apis.md.
  2. Every function has a LuaCATS doc comment directly above it (no blank line) — see luacats.md.
  3. File headers use --- @namespace, never --- @module.
  4. Lua idioms: ~= / not / and / or — never != / ! / && / || (GMod accepts the C forms silently; glualint rejects them). Semicolons only to pack multiple statements on one line (if x then y = 1; return y end) — a trailing local x = 5; or end; is valid Lua that glualint won't flag, so remove any you find.
  5. Client UI goes through cityrp.ui — see ui-scaling.md — and takes its colours, fonts and patterns from ui-style.md.
  6. Commands: player tools on / via cityrp.command.add; staff (including RP Assistant) on ! via cityrp.acommands.AddCommand; always pass category, syntax, tip — see commands.md.
  7. Async callbacks re-check IsValid inside the callback — see AGENTS.md footgun #2.
  8. Do not introduce umsg.*, SendLua(), or BroadcastLua().

Pre-submit checklist

Use this before opening or updating a PR:

  • Touched functions have LuaCATS annotations (three dashes, no blank line before function)
  • No new ScrW() / 1920, LimelightHUD.GetScaleFactor(), or fixed UI surface.CreateFont sizes
  • No new "say /..." command literals — use cityrp ... / cityrp.command.consoleCommand
  • No new --- @module headers
  • No new cityrp.item.register / spawnable inheritance-only bases — use ITEM:New
  • No new NW/NW2 vars for player state — use SetNSVar / setLocalPlayerVariable
  • No new color_white / color_black — use COL_WHITE / COL_BLACK
  • No LDoc tags, two-dash -- @tag annotations, or blank lines between a doc comment and its declaration
  • New commands have category + syntax + tip on the correct / or ! API
  • New extension points follow Hooks: a GM: default for each veto hook, guards before hook.Run, one lifecycle hook per action
  • Logging uses cityrp.logs.add (audit) or cityrp.logging.GetLogger (diagnostics), not print
  • glualint clean on changed lines: python3 .gitea/scripts/lint-diff.py --base origin/master gamemodes/cityrp_2_by_limelightgaming
  • Standards patterns clean on changed lines: python3 .gitea/scripts/standards-diff.py --base origin/master

What review / CI will reject

These are treated as standards violations on lines your diff adds or modifies (pre-existing legacy elsewhere in a file is not enough reason to add more of it):

Pattern on a changed line Why
ScrW() / 1920 (or ScrH() / 1080) Bypass of cityrp.ui
LimelightHUD.GetScaleFactor() Same — use cityrp.ui (grandfathered only under core/derma/cl_hud_*.lua until migrated)
--- @module LuaLS drops the entire doc comment — use @namespace
A "say /..." / "say !..." literal, wherever it is built Chat-pipeline detour — footgun #13
umsg.*, SendLua(, BroadcastLua( Banned networking
A github_pat_… / ghp_… token, or a quoted 36-character mixed-case alphanumeric string, even in a comment Committed API token: revoke it, because deleting the line leaves it in history. Read tokens with ENV:Get — see environment.md
cityrp.item.register( Legacy item API — use ITEM:New
ply:SetNW* / SetNW2* on a player NW vars are the avoided tier for player state — use SetNSVar / setLocalPlayerVariable
color_white / color_black Use COL_WHITE / COL_BLACK from sh_enumerations.lua
LDoc tags (@tparam, @treturn, @classmod, @usage, @number, @local), @vararg Not LuaCATS — they render nowhere, on two dashes or three
Any two-dash -- @tag line, --[[--- @as T]] Invisible to LuaLS — annotations need three dashes, inline casts take none
A file-level function your diff adds with no --- comment above it Non-negotiable 2 — see luacats.md
A blank line between a doc comment and its declaration LuaLS never associates them; the annotation is dropped
cityrp.command.add / cityrp.acommands.AddCommand without category, syntax and tip F1 shows "No description." — see commands.md

The annotation, colour-constant and NW-var rules apply under gamemodes/cityrp_2_by_limelightgaming/ only: vendored addons are not on the docs site and are not ours to re-plumb. The doc-comment rules look at file-level declarations, so panel callbacks and nested helpers are review's job, not CI's.

C-style operators are rejected by glualint via lint-diff.py. Patterns in the table above are rejected by standards-diff.py. Both run in the Code Linting workflow, which tests its own diff gates first (test-diff-scripts.py) — these checks fail open when there is nothing to diff, so a base resolved to the wrong commit would report success without checking anything.

Warnings (do not fail CI)

Three standards-diff.py checks report as a ::warning:: instead of ::error:: and do not fail the run.

net-entity-unchecked flags a server-side net.Receive handler your diff touches that indexes a net.ReadEntity() result before checking it with IsValid. A client can name any entity, or none, so validate a client-named entity before using it. The handler can be an inline function or one named and defined in the same file. Either of these counts as the check, as long as it comes before the first ent: or ent. use:

  • IsValid(ent) or ent:IsValid()
  • passing ent to a validator in an if condition, such as if not IsCharger(ent) then return end

The rule asks only for validity: class, owner and distance checks depend on what the handler is for. cl_*.lua files, client/ folders, if CLIENT then blocks and the else of if SERVER then are exempt. It reads one handler at a time, so aliasing the entity to another variable, or a handler defined in another file, gets past it. That is review's job.

excessive-prose flags a 4+ line comment block your diff adds, outside a file-level header, that isn't LuaCATS annotations (footgun #12 — comment only where a reader would be surprised). It's a warning rather than a hard reject because there's no whitelist for a genuinely long, legitimate explanation — the same check runs locally as a PostToolUse hook (.claude/hooks/check-comment-blocks.py) that surfaces the same nudge mid-edit instead of at review time.

net-write-localplayer flags a net.WriteEntity your diff adds whose argument is LocalPlayer(), or a local assigned from LocalPlayer() earlier in the same function. The server already knows who sent a message: it is the second argument to the net.Receive callback, function(len, ply). A client that writes its own player in invites the handler to read that entity back and trust whichever player a client names. Drop the write and use the callback's ply instead. Files that are server realm by name (sv_*.lua, init.lua, or under an sv/ or server/ folder other than gamemode/core/server/) are exempt.

When you touch legacy code

Editing a file that still uses a forbidden pattern does not require rewriting the whole file. It does require that your new or modified lines follow this page. Prefer migrating the local call site when the change already has to understand that code (especially UI scale).