A set of guidelines to help keep your GLua code Clean and Cool 😎
Who this is for: beginners and AI agents. If you are an experienced developer with an established style — you do not need this repo; take whatever fits, the only hard principle is consistency.
This project is a fork of CFC-Servers/cfc_glua_style_guidelines. It improves the guidelines and ships an AI skill — the most effective way to give an agent access to a style guide: the skill's
nameanddescriptionare always injected into the agent's context, and the agent loads the full rules on its own the moment they become relevant. Some practices are adapted from the GmodStore script submission guidelines.
Feel free to use these guidelines as you see fit! The most important principle is consistency — if you choose to follow or ignore certain rules, do so consistently throughout your code. 👍
This repository ships a SKILL for AI coding agents (OpenCode, Claude Code, Codex, Cursor). It makes the guidelines visible to agents in every project: the skill's name and description are always provided to the agent, while the full rules are loaded on demand when the agent decides they are relevant.
The skill lives in skills/glua-style/ and contains:
SKILL.md— the rules themselves, in an imperative, agent-friendly format, with a self-check checklistreferences/style-rules.lua— a compact rule summary in code formreferences/example-good.lua/references/example-bad.lua— full example files showing the guidelines in practice
Code produced with this skill structurally passes the GmodStore AI Usage Policy: it requires structure, consistent naming and proper file separation in AI-generated products — exactly what the skill enforces, instead of raw flat-dump output.
Copy the skills/glua-style/ folder into your agent's skills directory:
- Global (OpenCode, Codex, Cursor, Gemini CLI, Copilot, and other Agent Skills tools) — user-wide:
~/.agents/skills/, per project:.agents/skills/ - Claude Code — user-wide:
~/.claude/skills/, per project:.claude/skills/ - Trae — user-wide:
~/.trae/skills/, per project:.trae/skills/
cp -r skills/glua-style ~/.agents/skills/Claude Code and Trae don't read ~/.agents/skills/, so install it there too if you use them. Or just paste the repo URL into your agent and ask it to install the skill. After installing, ask your agent to write some GLua code — it should automatically load the glua-style skill and follow these guidelines. If it doesn't pick it up, tell it explicitly: "Load the glua-style skill and apply its rules."
- OpenCode (user-wide):
~/.config/opencode/skills/ - OpenCode (per project):
.opencode/skills/ - Claude Code (user-wide):
~/.claude/skills/ - Claude Code (per project):
.claude/skills/ - Codex (user-wide):
~/.codex/skills/ - Codex (per project):
.codex/skills/ - Cursor (user-wide):
~/.cursor/skills/ - Cursor (per project):
.cursor/skills/
Use GLuaLint (GLuaFixer) as your linter. There are plugins available for most major editors.
GLuaFixer: https://github.com/FPtje/GLuaFixer
Use the GLuaFixer config from this repository: configs/glualint.json
Use StyLua as your formatter — the config matches these guidelines (tabs, double quotes, no inner spacing, always-parentheses) and intentionally deviates from StyLua's defaults where noted. The config lives in configs/stylua.toml. Enable formatOnSave in your editor.
Tip
The VS Code extension has no global formatter defaults (JohnnyMorganz/StyLua#978). StyLua-wrapper works around it: point stylua.styluaPath at the wrapper and every config-less project gets seeded with your default template until the feature lands upstream.
Important
Do not set syntax in stylua.toml. StyLua has no GLua mode (#930, open since 2024), and no single dialect fits: syntax = "LuaJIT" — technically the correct one for GMod — fails on every continue, while syntax = "Luau", the only mode that accepts continue, fails on goto labels. GLua formats only in the default All mode, and only because that union happens to include the Luau grammar, where continue is a keyword. Leave it unset.
Two consequences of running StyLua on GLua are worth knowing up front. It rewrites every table into the spaced style ({ 5, {} }), so with formatOnSave enabled GLuaLint will report lint_spaceBetweenBraces warnings on code the formatter just produced — see the warning under No spaces inside parentheses and curly braces. And it adds a trailing comma to every multiline table, which is why the guide keeps them (they are a syntax error in argument lists — see Multiline function calls).
A ready-to-use VS Code settings template is in configs/.vscode/settings.json — copy it to your project's .vscode/settings.json or merge the relevant keys into your user settings. It enables GLuaFixer on save, sets StyLua as the Lua formatter, and adds formatOnSave for .lua files.
The template depends on three extensions:
- StyLua — the formatter both language sections point at
- vscode-glualint — the linter behind the
glualint.*keys - GLua Enhanced — registers the
glualanguage id
Warning
GLua Enhanced is a hard requirement for the files.associations block. Without it, *.lua files are mapped to a language that does not exist, so neither the [glua] nor the [lua] section applies and formatOnSave silently stops working. If you don't want that extension, drop the files.associations block from the template.
Good
local x = a * b + cBad
local x = a* b+cThe compact style (f(x), {5, {}}) keeps the code tight. For parentheses and square brackets it matches modern formatters (e.g. StyLua) and is a requirement; for curly braces it is a preference (see the warning below). The spaced style (f( x )) is a legacy convention that bloats the code and breaks with autoformatters
Good
local x = (3 * myFunc()) + 5
local data = {5, {}}Bad
local x = ( 3 * myFunc() ) + 5
local data = { 5, {} }Warning
StyLua breaks this rule for curly braces: it always formats tables as { 5, {} } and offers no option to disable it. The brace_spacing PR (#628) has been open since 2022 with no sign of being merged. Until that changes, treat compact braces as preferred, not required — and expect formatOnSave to undo them. Parentheses and square brackets are unaffected: StyLua agrees with the guide there.
A space after a comma makes arguments and table entries easy to scan at a glance.
Good
myFunc(10, {3, 5})Bad
myFunc(10,{3,5})Tabs are the default of modern formatters (e.g. StyLua) and let every developer set their own tab width. Use a 4-space tab stop. You may add an extra indentation level to visually group code between paired calls (e.g. cam_Start3D/cam_End3D, render.PushRenderTarget/render.PopRenderTarget)
Good
if cond then
myFunc()
endBad
if cond then
myFunc()
endMatches the compact style of modern formatters; the spaced style adds noise without improving readability.
Good
local val = tab[5] + tab[3]
local val2 = tab[5 * 3]Bad
local val = tab[ 5 ] + tab[ 3 ]
local val2 = tab[ 5 * 3 ]The space keeps the comment visually separated from the code it documents.
Good
-- This is a good comment
local a = 3 -- This is also goodBad
--This comment doesn't have a space before it
local a = 3-- This comment starts too close to the 3Good
local config = GM.Config
function GM:Think()
-- do thing
endBad
local config = GM.Config
function GM:Think()
-- do thing
endSeparate top-level blocks with a single newline. Only add a section header comment when it conveys information beyond the block's name
Good
local config = GM.Config
function GM:Think()
-- do thing
endBad, no visual separation between unrelated groups
local config = GM.Config
function GM:Think()
-- do thing
endA restating header like
-- Thinkabovefunction GM:Think()is a useless comment (see Comments) — rely on the blank line and a meaningful function name instead.
Good
function test()
local a = 3
print(a)
endBad
function test()
local a = 3
print(a)
endGood
function test()
local a = 3
return a
end
function test2()
return 3
endBad
function test()
local a = 3
return a
endException: in guard-clause chains (consecutive early returns) no blank line is required between the guards and the final return:
function test(x)
if x < 0 then return end
return x
endGood
function MyAddon.ResolveFilter(filter)
if type(filter) == "Player" then
filter = {filter}
end
if type(filter) == "table" then
filter = fWrap(filter)
end
filter = filter or player.GetAll
local players = filter()
if type(players) == "Player" then
players = {players}
end
if not players then players = player.GetAll() end
return players
endBad, far too dense and difficult to read at a glance
function MyAddon.ResolveFilter(filter)
if type(filter) == "Player" then
filter = {filter}
end
if type(filter) == "table" then
filter = fWrap(filter)
end
filter = filter or player.GetAll
local players = filter()
if type(players) == "Player" then
players = {players}
end
if not players then players = player.GetAll() end
return players
endGood
if a ~= b and not b then endBad, Garry's operators aren't recognized by standard Lua highlighting
if a != b && !b then endGood
--[[
This line does stuff
]]
doStuff() -- Stuff being doneBad, gmod comments aren't recognized by standard Lua highlighting
/*
This line does stuff
*/
doStuff() // Stuff being doneGMod's continue is a convenient extension for simple one-line filters. Use it to flatten loop bodies.
Good
for _, v in ipairs(tab) do
if IsValid(v) == false then continue end
v:Remove()
endAvoid, the nesting pyramid grows with every filter
for _, v in ipairs(tab) do
if IsValid(v) then
if v:IsReady() then
v:Remove()
end
end
endIf your condition is more complicated, consider deferring the logic to another function
local function processItem(item)
if (IsValid(item) and item:IsReady()) == false then return end
item:Process()
end
for _, item in ipairs(tab) do
processItem(item)
endNote
continue is error-prone inside repeat-until loops (see the wiki) - prefer a plain for/while loop there.
Only return a value from a hook when you intend to override or block the default behaviour. If your condition fails, return nothing — returning false blocks the hook for every other addon and the game's default logic
Good, returning nothing when the condition doesn't match
hook.Add("PlayerUse", "gmod.one/myaddon/vending-gate", function(ply, ent)
if ent:GetClass() ~= "myaddon_vending_machine" then return end
if ply:IsAdmin() == false then return false end
return true
end)Bad, blocks PlayerUse for everyone, everywhere
hook.Add("PlayerUse", "gmod.one/myaddon/vending-gate", function(ply, ent)
if ply:IsAdmin() then
return true
end
return false -- every player can no longer use ANY entity
end)When detouring a function, always call the original. Think carefully about backwards compatibility and unintended consequences when detouring a public API — other addons may rely on its behaviour:
Good
local originalFunc = someLib.DoSomething
function someLib.DoSomething(...)
-- extend the behaviour here (side effects)
return originalFunc(...)
endIf you need to modify the returned values, pack them explicitly instead of copying every vararg unconditionally:
local originalFunc = someLib.DoSomething
function someLib.DoSomething(...)
local results, count = table.Pack(originalFunc(...))
results[1] = transform(results[1])
return unpack(results, 1, count)
endGMod runs on LuaJIT (Lua 5.1): table.pack and table.unpack do not exist. Use GMod's table.Pack — it returns the table and the number of packed items — together with the global unpack. Keep the count: #results stops at the first nil, so a return list with holes or trailing nils would be truncated.
Garry's Mod loads files based on their location — use that instead of gating code at runtime:
lua/autorun/*.lua— loaded on both server and clientlua/autorun/server/*.lua— server onlylua/autorun/client/*.lua— client only- everything else — wherever you
include/AddCSLuaFileit
Bad, the whole file is executed on both realms just to bail out
-- myaddon/shared_but_client_only.lua
if SERVER then return end
-- 200 lines of client codeGood, each file is only loaded where it's needed
-- lua/autorun/myaddon.lua
local load = {
cl = SERVER and AddCSLuaFile or include,
sv = SERVER and include or function() end,
sh = function(path)
AddCSLuaFile(path)
include(path)
end
}
load.sh("myaddon/shared.lua")
load.cl("myaddon/client.lua")
load.sv("myaddon/server.lua")The autorun file should only load other files via include / AddCSLuaFile — no logic in it. Autorun paths must be unique across addons: if several addons register the same path (e.g. lua/autorun/init.lua), the VFS loads only one of them and the others break (hence the instant denial on GmodStore). Name your autorun file after your addon (lua/autorun/myaddon.lua) and keep all addon files inside your own uniquely named folder — myaddon/init.lua is fine because the path is unique. When integrating with another addon, write only your own uniquely named file into its module folder — never a generic name like logs.lua
Bad, logic in autorun and a colliding name
-- lua/autorun/init.lua
timer.Create("my_addon_tick", 1, 0, function() doThing() end)Good, autorun only loads, everything else lives in lua/myaddon/
-- lua/autorun/myaddon.lua
include("myaddon/shared.lua")Huge single files are hard to navigate and fail AI-review expectations (GmodStore explicitly rejects "singular megafiles"). Split the addon into logical modules — one file per concern (config, hooks, net, rendering), each staying manageable.
Server-only files must never be registered with AddCSLuaFile — it sends their source to every client, letting scriptkiddies dump your private logic. AddCSLuaFile is for shared and client files only.
If a method needs heavy if SERVER then ... else ... end branching, it probably doesn't belong in a shared file. Write separate server and client implementations instead.
util.AddNetworkString is a server-only call. Do it at the top of the file or in init - never inside hooks, timers, or dynamic functions. Clients resolve the string name automatically.
Good
if SERVER then
util.AddNetworkString("gmod.one/myaddon/player-count")
endPrefer net.WriteUInt (unsigned), net.WriteInt (signed), net.WriteFloat (fractional) and the typed writers over net.WriteType / net.WriteTable — the generic writers are expensive. Send static structures; never net.WriteTable per message when the shape is known.
Never send data the client already has (localisation strings, class lists) — send a key, the client resolves it. Never send data the client doesn't use, and never set DataTable vars the client never reads.
Player-triggered net messages must be rate-limited on the server — reject requests above a per-player cooldown. If a client can spam a message, debounce it client-side before calling net.SendToServer(). Incoming data must be type-validated. The server is the only source of truth — never trust the client, never use SendLua / BroadcastLua, and never trust an entity the client sends via net.WriteEntity(LocalPlayer()) — use the player argument passed to your net.Receive callback as the only source of truth for the sender.
Setting DataTable vars or sending net messages from Think, Move or other per-frame hooks floods the network. Update on change, not per frame.
Client→server messages are more expensive than the reverse (they can be dropped by the server). Send them only when necessary and keep them small.
Good
local myVariable = 10Bad
local MyVariable = 10
local my_variable = 20Constants are SCREAMING_SNAKE regardless of scope — local or global:
Good
CATEGORY_NAME = "nothing"
local MAXIMUM_VALUE = 25Bad
CategoryName = "nothing"
categoryName = "nothing"
category_name = "nothing"
local maximumValue = 25See also: Globals for where the line between a global constant and a mutable global runs.
Good
GlobalVariable = 10Bad
globalVariable = 20
global_variable = 20Global constants (immutable values) follow the constants rule instead — SCREAMING_SNAKE (MAX_PLAYERS = 32, CATEGORY_NAME = "nothing"). PascalCase is for mutable globals: namespace tables, config tables, counters.
Applies both to :-defined methods and to functions stored in library/namespace tables — like hook.Run, player.GetAll, vgui.Register. camelCase dot-functions (e.g. math.floor) are considered bad style.
Good
function classTable:SetHealth(amount)
end
function someLib.ResolveFilter(filter)
endBad
function classTable:setHealth(amount)
end
function someLib.resolveFilter(filter)
endNamed keys accessed via dot notation should only contain a-z A-Z 0-9 _ . and must not start with a digit
This applies to keys you access with dot notation (t.key ⇔ t["key"]). Keys used only through brackets — t["my-value"], localisation strings, integer array indices — are unrestricted.
Good
myTable.myValue = 4Bad, accessing the table value requires brackets and quotes because of the hyphen
myTable["my-value"] = 4Good
for _, ply in ipairs(player.GetAll()) do
local _, shouldKill = ply:GetKillData()
if shouldKill then
ply:Kill()
end
endBad, k isn't used
for k, ply in ipairs(player.GetAll()) do
local canKill, shouldKill = ply:GetKillData()
if shouldKill then
ply:Kill()
end
end- Hook identifiers should be named as namespaced paths in the form
orgName/productName/eventPurpose, using slashes and hyphens (e.g.gmod.one/true-printers/cleanup-props)productName/eventPurpose(without the org prefix) works too when the product name is unique — this guide prescribes the full form because it is unambiguous. - The hook event name should not be included in the identifier.
For example, you should not do
orgName/productName/on-disconnector evenorgName/productName/cleanup-props-on-disconnect. ButorgName/productName/cleanup-propswould be appropriate. The "eventPurpose" should state what a function does without restating information provided by the event name. - Custom hook events (fired via
hook.Run) should use the same namespaced format:orgName/productName/eventName(e.g.gmod.one/myaddon/player-count-changed) - Net message names and timer identifiers should use the same namespaced format:
gmod.one/myaddon/player-count,gmod.one/myaddon/delayed-.. steamID64
One quote style for the whole codebase — no mixing and no deciding per string. For strings full of quotes or line breaks, use long bracket strings instead of escaping (see below).
Good
myFunc("hello ", "world!")
local warning = "say \"hi\" instead of 'hi'"Bad, single quotes
myFunc("hello ", 'world!')Escaping every quote turns into quote soup. Long bracket strings ([[...]]) keep the content verbatim — use them for strings that contain multiple quotes or line breaks:
Good
local notice = [[Say "hi" — no escaping needed here]]Bad, quote soup
local notice = "Say \"hi\" \n second line \"again\""Good
if x == y thenBad, these parenthesis are not necessary
if (x == y) thenRedundant parenthesis may be used if it makes the order of operations clear. The author should use their best judgement
It's important for the author to remember who their target audience is. Not every reader will be good with maths, and it can be helpful to make equations easy to follow
Acceptable
local amount = (quantity * modifier) + baseAmountUnsavory, but fine
local amount = quantity * modifier + baseAmountThis is a recommendation, not a hard rule. not x is idiomatic Lua and perfectly fine, especially for nil checks. When the value is guaranteed to be boolean (e.g. IsValid, ply:Alive()), an explicit comparison states your intent and makes the code self-documenting — prefer it there, but pick whichever reads better in context and stay consistent.
Good, the check is explicit
Single check:
if IsValid(ply) == false then return endGrouped check — if IsValid(ply) is false, the and-chain short-circuits and ply:Alive() is never called:
if (IsValid(ply) and ply:Alive()) == false then return endNil-safe check — for values that may be unset (nil and false both mean "not premium"):
if playerData.isPremium ~= true then return endAlso fine, not is idiomatic and reads like English
if not (IsValid(ply) and ply:Alive()) then return endNote
not x and x == false differ when x is nil: not x catches both, x == false only catches false. Use not x (or x ~= true) for values that may be nil; use x == false only when the value is guaranteed boolean.
Elements should begin on the next line and the last line should contain only a closing bracket. Elements inside should be indented once
Good
tbl = {
v1 = c,
v2 = d,
v3 = k,
v4 = j,
v5 = y,
}Fine, if both the keys and values are short, it's acceptable to keep them in one line
tbl = {v1 = c, v2 = d, v3 = k, v4 = j, v5 = y}Bad, the indentation is inconsistent
tbl = {v1 = v, v2 = d,
v3 = k, v4 = j}Good
myFunc(
"First arg",
secondArg,
{third, arg}
)Important
Trailing commas are legal in table constructors but not in argument lists — a trailing comma before ) is a syntax error. StyLua adds one to every multiline table automatically, so the two cases look inconsistent on purpose.
Bad, this indentation is inconsistent (and objectively wrong)
myFunc("First arg",
secondArg, {third, arg})A multiline table argument follows the table rules: elements on new lines, closing bracket on its own line. A short nested table may be passed compactly on a single line.
Good, multiline table argument
surface.CreateFont("MyFont", {
font = "Arial",
size = 13,
weight = 500,
})Good, short nested table passed compactly
myFunc("First arg", secondArg, {
v1 = firstValue,
v2 = secondValue,
})Good
function test()
if not myThing then return end
-- Do a bunch of real complicated things
endBad, adds an extra level of indentation for no reason
function test()
if myThing then
-- Do a bunch of real complicated things
end
endThis applies to bare literals in logic (conditions, arithmetic, calculations). Configuration data grouped in named config tables/constants (prices, timings, limits) is not magic.
Good
local maxX = 25
function checkX(x)
return x > maxX
endBad, the significance of 25 is unknown without a meaningful variable name
function checkX(x)
return x > 25
endGood, each step of the equation is named and done individually. The math is easy to follow
local widthModifier = amount * damageMult
local age = 1 - lifetime / duration
local width = widthModifier * ageBad, this math is difficult to figure out from a glance
local width = (amount * 5) * (1 - lifetime / duration)String patterns (used in string.match, string.gsub, etc.) full of escapes are unreadable on one line. Build them with table.concat and indentation so each piece is visible
Good
local avatarPattern = table.concat({
"<avatarIcon>",
"<%!%[", "CDATA%[",
"(.-)",
"%]%]>",
"</avatarIcon>"
})Bad, impossible to scan
local avatarPattern = "<avatarIcon><%!%[CDATA%[(.-)%]%]></avatarIcon>"Patterns are constants: build them once at file scope, never inside a hot loop — the table.concat cost is paid once and becomes irrelevant. Trade-off: a split pattern is harder to paste into a pattern tester for debugging; keep the pieces on separate lines so they can be re-assembled by eye. (Long bracket strings are not a good alternative for patterns — they include the surrounding newlines and whitespace, breaking the pattern.)
Semicolons provide no functional value in Lua. While they can be used to delimit table items, a comma is preferred
Good
local a = 3
print(a)
return aBad, this exclusively makes the code harder to read
local a = 3; print(a);
return a;Good
local circumference = radius * math.pi * 2
local radians = math.rad(deg)Bad, the meaning of 3.142 may not be immediately clear. It also suffers a loss in precision compared to math.pi
local circumference = radius * 3.142 * 2
local radians = deg * (3.142 / 180)Good, each check is clear and it's easy to follow the reasoning
local entValid = IsValid(ent)
local entOwner = ent:CPPIGetOwner()
local ownerVehicleIsVisible = entOwner:GetVehicle():GetColor().a > 0
local ownerCapable = entOwner:IsAdmin() or entOwner.canDoThing
if entValid and ownerVehicleIsVisible and ownerCapable then
-- doThing
endBad, the conditions are difficult to follow
if IsValid(ent) and ent:CPPIGetOwner():GetVehicle():GetColor().a > 0
and (ent:CPPIGetOwner():IsAdmin() or ent:CPPIGetOwner().canDoThing) then
-- do thing
endNote
Named conditions are evaluated eagerly — every check runs even if the first would have failed. If checks are expensive or order matters, prefer flat early returns (see Return early from functions) over pre-computing every condition.
If a condition doesn't fit on one line, split it across lines with operators at the start of each line
While pulling conditions into variables is preferred, sometimes it isn't practical. In that case, break the condition onto multiple lines, keeping operators at the start of each line so the logic is easy to scan.
Good
if
IsValid(ent) and
ent:IsPlayer() and
(ent:IsAdmin() or ent:IsSuperAdmin())
then
-- do thing
endBad, operators at the end of the line are harder to scan, and the dangling then is easy to miss
if IsValid(ent) and
ent:IsPlayer() and
(ent:IsAdmin() or ent:IsSuperAdmin()) then
-- do thing
end~110 is the soft target, 120 is the hard limit enforced by configs/glualint.json (lint_maxLineLength). Lines between 110 and 120 are acceptable, but they are the exception, not the norm. Comments count too
Good
if IsValid(ent) and ent:IsPlayer() and ent:IsAdmin() then
local hue = (CurTime() * 10) % 360
local color = HSVToColor(hue, 1, 1)
ent:SetColor(color)
endBad, this line is too long and could easily be split into multiple, easy-to-follow lines
if IsValid(ent) and ent:IsPlayer() and ent:IsAdmin() then ent:SetColor(HSVToColor((CurTime() * 10) % 360, 1, 1)) ent:SetRenderMode(RENDERMODE_TRANSALPHA) endPrefer decimals with a leading zero (0.69 not .69), whole numbers without leading zeros (420 not 00420), and no trailing zeros (0.2 not 0.200) — unless trailing zeros convey precision in a column of configurable values.
Comments are an exception, not a default — add one only where the code genuinely cannot explain itself (non-obvious logic, workarounds, external constraints). Restating comments (-- set player) and section-label noise are bad practice. Good variable and function names make most comments unnecessary — strive for self-documenting code
Good
for _, ply in ipairs(players) do
print(ply)
if ply:Alive() then ply:Kill() end
endBad, the code explains itself without comments
for _, v in pairs(stuff) do -- loop through players
print(v) -- print the player
if v:Alive() then v:Kill() end -- kill player if player is alive
endWhile these functions are relatively low-cost on their own, they still add overhead in places where it may not be necessary
Developers have been conditioned to pepper these checks anywhere-and-everywhere to avoid Attempt to index NULL errors.
If you know an Entity can become NULL, first try to remedy the underlying issue that causes it. Only rely on IsValid if the situation is outside of your control (i.e. delayed timer actions).
Good, IsValid is only used in pieces of code that we can't avoid, and isn't used in places that add no value
net.Receive("gmod.one/myaddon/process-request", function(_, ply)
-- No IsValid check is needed here, Players cannot become invalid at this point
ply:ChatPrint("Starting the process now...")
local steamID64 = ply:SteamID64()
timer.Create("gmod.one/myaddon/delayed-" .. steamID64, 5, 1, function()
-- IsValid check is required here because the Player may have disconnected before this timer triggers
if IsValid(ply) == false then return end
ply:DoAction()
timer.Create("gmod.one/myaddon/delayed-step2-" .. steamID64, 5, 1, function()
-- No IsValid check is needed here because we already saved the data we need from their Player object
Database:UpdatePlayer(steamID64, true)
end)
end)
end)Bad, IsValid is used in a hook where it's impossible for the Player to have become invalid
hook.Add("PlayerCanSuicide", "gmod.one/myaddon/admin-freeze-check", function(ply)
if IsValid(ply) == false then return end
if ply:IsAdminFrozen() then return false end
end)print accepts any number of varargs, and the values will be automatically spaced out and coerced into strings
Bad, uses a lot of string concatenation and many tostring calls, difficult to read at a glance
local function botDebugInfo(bot)
print(tostring(bot) .. " Health: " .. bot:Health() .. " Pos: " .. tostring(bot:GetPos()) .. " Alive: " .. tostring(bot:Alive()) .. " Weapon: " .. tostring(bot:GetActiveWeapon()) .. " Value: " .. tostring(bot:GetNWString("test")))
endGood, uses print's vararg feature with string casting and auto-spacing
local function botDebugInfo(bot)
print(
bot,
"Health:", bot:Health(),
"Pos:", bot:GetPos(),
"Alive:", bot:Alive(),
"Weapon:", bot:GetActiveWeapon(),
"Value:", bot:GetNWString("test")
)
endPrint varargs are great for quick debug output, but they only insert tabs between values. When you need precision, alignment, or values embedded in the middle of a string, use string.format instead of concatenation
Good, format specifiers give full control
print(string.format("%s | HP: %d | Speed: %.2f", ply, ply:Health(), ply:GetVelocity():Length()))Bad, same output via concatenation — hard to scan and full of tostring calls
print(ply .. " | HP: " .. ply:Health() .. " | Speed: " .. string.format("%.2f", ply:GetVelocity():Length()))Good, relies on the built-in feature of Lua string concatenation
for i = 1, #lines do
local line = lines[i]
print("Line #" .. i, line)
endBad, tostring simply isn't necessary when you're 100% sure that the value is a number
for i = 1, #lines do
local line = lines[i]
print( "Line #" .. tostring( i ), line )
endGMod keeps some APIs in an experimental state for years — their behavior may change without notice. Avoid experimental APIs altogether where possible: GmodStore disallows NW2Var entirely (see garrysmod-issues #5455), and so should you — use the stable, fully documented counterparts
Good, uses the stable NWVar family
local value = ent:GetNWInt("my_key")
ent:SetNWInt("my_key", value)The stable family is typed — SetNWInt, SetNWFloat, SetNWBool, SetNWString, SetNWEntity, SetNWAngle, SetNWVector — and that is the point: there is no generic SetNWVar, you pick the type up front. SetNW2Var accepts anything, which is exactly the flexibility that keeps it experimental.
Bad, GetNW2Var/SetNW2Var have been marked experimental for a long time
local value = ent:GetNW2Var("my_key")
ent:SetNW2Var("my_key", value)Deprecated APIs are legacy — Facepunch will not remove them (old addons depend on them), which is exactly why using them in new code is just bad form. Modern counterparts are better in performance and features. The common ones: umsg (use the net library), self.Owner in SWEPs (use GetOwner()), self.Weapon (it is the SWEP).
Good
net.Start("gmod.one/myaddon/shot-fired")
net.WriteUInt(amount, 16)
net.Send(ply)Bad, deprecated
umsg.Start("ShotFired", ply)
umsg.Short(amount)
umsg.End()Prefer sending data with the net library. Network variables (SetNWInt, SetNWBool and friends) are for data that is read frequently and must be available instantly (e.g. entity state the client reads every frame); use them only when it's genuinely better than a net message. NW2Var stays banned entirely.
Think hooks, render loops and other hot paths hit the global table on every call. Localizing the functions you use most at the top of the file skips that lookup. Verify hot paths with a profiler first and localize what the profiler flags — not everything; over-localizing hurts readability and hits LuaJIT limits
Good, globals are resolved once at file load
local IsValid = IsValid
local RealTime = RealTime
hook.Add("Think", "gmod.one/myaddon/hot-loop", function()
if IsValid(ply) and RealTime() > nextUpdate then
-- ...
end
end)Bad, every call walks the global table
hook.Add("Think", "gmod.one/myaddon/hot-loop", function()
if IsValid(ply) and RealTime() > nextUpdate then
-- ...
end
end)Good, caching a hot method is usually more impactful — indexing the PLAYER metatable runs __index on every call, which matters in per-frame loops:
local PLAYER = FindMetaTable("Player")
local plyHealth = PLAYER.Health
local lowHealth = {}
hook.Add("Think", "gmod.one/myaddon/low-health-scan", function()
for _, ply in player.Iterator() do
lowHealth[ply] = plyHealth(ply) < 25
end
end)Note
LuaJIT caps upvalues per function at 60 — localize only what you actually use in hot code, don't localize everything.
Note
The GLua Enhanced VS Code extension can auto-generate these localizations for you.
Color(), Material(), Vector(), Angle() allocate new objects. Construct them once at file scope, never inside DrawHUD, Think or per-frame functions:
Good
local LINE_COLOR = Color(255, 200, 100)
hook.Add("HUDPaint", "gmod.one/myaddon/crosshair-lines", function()
surface.SetDrawColor(LINE_COLOR)
surface.DrawLine(ScrW() / 2 - 8, ScrH() / 2, ScrW() / 2 + 8, ScrH() / 2)
end)Bad, allocates a new Color every frame
hook.Add("HUDPaint", "gmod.one/myaddon/crosshair-lines", function()
surface.SetDrawColor(Color(255, 200, 100))
surface.DrawLine(ScrW() / 2 - 8, ScrH() / 2, ScrW() / 2 + 8, ScrH() / 2)
end)World-space (3d2d) drawing runs every frame for every entity in view. Always bail out with a distance check before drawing, and add a visibility check where it matters:
Good
local DRAW_DISTANCE_SQR = 512 ^ 2 -- 512 units
if
LocalPlayer():GetPos():DistToSqr(ent:GetPos()) > DRAW_DISTANCE_SQR or
CamIsVisible(ent:GetPos()) == false
then return end1. Embed the organization name in the global name, e.g. ValvePrinters, RubatSkillsSystem — never generic names like Config or PlayerCount
4. Use local function for file-internal helpers; function ValvePrinters.Foo() (global) only when other files need to call it
Good, a single unique namespace global
ValvePrinters = ValvePrinters or {}
ValvePrinters.CONFIG = {}
ValvePrinters.playerCount = 0
hook.Add("PlayerInitialSpawn", "gmod.one/myaddon/track-players", function()
ValvePrinters.playerCount = ValvePrinters.playerCount + 1
end)Bad, bare globals with generic names that will collide
PlayerCount = 0
Config = {}
hook.Add("PlayerInitialSpawn", "gmod.one/myaddon/track-players", function()
PlayerCount = PlayerCount + 1
end)Good, keep state file-local when nothing outside the file needs it
local playerCount = 0
hook.Add("PlayerInitialSpawn", "gmod.one/myaddon/track-players", function()
playerCount = playerCount + 1
end)Good, when another file needs the value — a module return is the preferred way to share it (use it only when it makes sense; most state stays file-local)
-- myaddon/init.lua
local MyAddon = {}
local playerCount = 0
function MyAddon:GetPlayerCount()
return playerCount
end
return MyAddonGood, file-internal helpers are always local
local function validateInput(data)
if type(data) ~= "table" or data.id == nil then return false end
return true
end
local function processRequest(ply, data)
if validateInput(data) == false then return end
-- do the real work
end
-- only hooks and public functions are global
hook.Add("PlayerUse", "gmod.one/myaddon/use-gate", function(ply, ent)
processRequest(ply, ent)
end)Bad, unnecessary global function — nothing outside this file calls it
function validateInput(data) -- should be local
if type(data) ~= "table" or data.id == nil then return false end
return true
endrequire caches the module, so pull the parts you actually use into locals. This makes dependencies explicit and avoids repeated table lookups on every call
Good, only the needed function is pulled out
local hmac = require("openssl").hmac.hmac
hmac("data", "key")Bad, every call re-looks-up the function in the module table
local openssl = require("openssl")
openssl.hmac.hmac("data", "key")Deeply nested code is hard to follow. As a rule of thumb, keep the nesting depth at three levels or fewer — combine this with early returns to keep functions flat. If a function still can't avoid pyramids, split it into several compact sub-functions, each doing one thing
Good, early returns keep the function flat
local function processPlayer(ply)
if (IsValid(ply) and ply:Alive()) == false then return end
local weapon = ply:GetActiveWeapon()
if IsValid(weapon) == false then return end
weapon:SetClip1(weapon:Clip1() + 1)
endBad, each check adds a level of indentation for no reason
local function processPlayer(ply)
if IsValid(ply) then
if ply:Alive() then
local weapon = ply:GetActiveWeapon()
if IsValid(weapon) then
weapon:SetClip1(weapon:Clip1() + 1)
end
end
end
endipairs or a numeric for i = 1, #t loop is roughly 4x faster than pairs on sequential numeric tables. Use pairs only for keyed tables
Good
for i = 1, #items do
processItem(items[i])
endBad, pairs on a sequential table - 4x slower
for _, item in pairs(items) do
processItem(item)
endtable.HasValue(roles, "admin") scans the whole table; {["admin"] = true} is a direct key lookup:
Good
local ALLOWED_ROLES = {["admin"] = true, ["superadmin"] = true}
if ALLOWED_ROLES[ply:GetUserGroup()] then
ply:ChatPrint("Welcome!")
endBad, scans the table on every call
if table.HasValue({"admin", "superadmin"}, ply:GetUserGroup()) then
ply:ChatPrint("Welcome!")
endplayer.GetAll() / ents.GetAll() rebuild the list on every call. In per-frame code use player.Iterator() / ents.Iterator(), or cache the list once and refresh it on spawn/disconnect:
Good, iterators are cheap
hook.Add("Think", "gmod.one/myaddon/aura-update", function()
for _, ply in player.Iterator() do
local isFast = ply:GetVelocity():Length() > 150
if ply:GetNWBool("gmod.one/myaddon/aura") ~= isFast then
ply:SetNWBool("gmod.one/myaddon/aura", isFast)
end
end
end)player.Iterator() returns the same three values as ipairs — iterator, cached table, start index — so the loop needs both variables: the first one is the numeric index, the second is the player.
Bad, player.GetAll() allocates a new list every frame
hook.Add("Think", "gmod.one/myaddon/aura-update", function()
for _, ply in ipairs(player.GetAll()) do
local isFast = ply:GetVelocity():Length() > 150
if ply:GetNWBool("gmod.one/myaddon/aura") ~= isFast then
ply:SetNWBool("gmod.one/myaddon/aura", isFast)
end
end
end)Logs, caches and per-player records eat memory without limits. Cap them implicitly (by player count) or explicitly (pagination, max entries)
Good
local MAX_LOGS = 200
local logs = {}
local function addLog(entry)
table.insert(logs, entry)
if #logs > MAX_LOGS then
table.remove(logs, 1)
end
endBad, grows forever
local logs = {}
local function addLog(entry)
table.insert(logs, entry)
endNever assume an object is in the state you expect. Validate data and permissions before risky operations - especially for actions triggered by players:
Good
if ent:GetClass() ~= "myaddon_turret" or ply:IsAdmin() == false then return end
ent:SetTarget(ply)Bad, assumes the entity class and player rank
ent:SetTarget(ply)Removing entities or changing physics objects inside Touch/StartTouch/collision hooks can crash the server. Use SafeRemoveEntityDelayed, never call Remove() synchronously, and don't touch PhysObject from these hooks. These are ENTITY: callbacks (ENTITY:StartTouch, ENTITY:Touch, ENTITY:EndTouch, ENTITY:PhysicsCollide) — you define them on the entity table, not through hook.Add, so there is no hook identifier to name:
Good
-- lua/entities/myaddon_collector/init.lua
local SAFE_REMOVE_DELAY = 0 -- you can keep it as 0, it means: remove entity in next tick.
function ENT:StartTouch(other)
if other:GetClass() ~= "myaddon_pickup" then return end
SafeRemoveEntityDelayed(other, SAFE_REMOVE_DELAY)
endBad, can crash the server
function ENT:StartTouch(other)
other:Remove()
endA Lua error in GMod does not crash the process — it prints the error to the console and aborts only the current call (that hook, that callback, that timer tick). The next call runs as if nothing happened. Wrapping code in pcall "so it won't crash" buys nothing and costs you the stack trace
An error swallowed by a pcall is a bug nobody will ever see — including you.
Bad, hides the stack trace and protects against nothing
hook.Add("PlayerSpawn", "gmod.one/myaddon/loadout", function(ply)
pcall(function()
giveLoadout(ply)
end)
end)Good, let it error — you get the trace and fix the cause
hook.Add("PlayerSpawn", "gmod.one/myaddon/loadout", function(ply)
giveLoadout(ply)
end)The real use case is a function that must reach its end regardless of one failing step: a loop where one bad record must not abort the remaining ones, or a risky operation followed by mandatory cleanup. The other legitimate cases are narrow and specific — parsing untrusted input (util.JSONToTable on player-supplied data), calling callbacks you don't own, and touching a third-party addon's API that may not be installed.
Good, one bad record must not abort the whole save
for _, ply in player.Iterator() do
local ok, err = pcall(savePlayerData, ply)
if ok == false then
ErrorNoHalt("myaddon: failed to save " .. ply:SteamID64() .. ": " .. tostring(err) .. "\n")
end
endA pcall with an empty handler is worse than no pcall at all. Report the error with ErrorNoHalt (message only) or ErrorNoHaltWithStack (message plus a stack trace) — neither breaks out of the current call stack. If all you need is a success flag, ProtectedCall(func, ...) is the better tool: it runs the function like pcall, but the error is still printed and still goes through the normal error handler (GM:OnLuaError), so nothing is hidden.
Static widths and heights break on other resolutions. Size major elements relative to ScrW()/ScrH() — cache these at file scope and refresh them in GM:OnScreenSizeChanged (re-create fonts and materials there too)
Good
local function updateLayout()
PLAYER_PANEL:SetSize(ScrW() * 0.25, ScrH() * 0.5)
end
hook.Add("OnScreenSizeChanged", "gmod.one/myaddon/layout", updateLayout)Bad, fixed pixels break on other resolutions
PLAYER_PANEL:SetSize(480, 300)If you integrate someone else's code or assets, credit the author in a comment at the top of the file and make sure you have permission for commercial use — GmodStore requires it, and it protects you from takedowns
Good
-- Requires permission: https://github.com/example/author (used under MIT)
local function borrowedHelper() doHelper() end