Skip to content
 
 

Latest commit

 

History

46 Commits

Folders and files

Repository files navigation

GLua Best Practices

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 name and description are 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. 👍


For AI Agents

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 checklist
  • references/style-rules.lua — a compact rule summary in code form
  • references/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.

Install

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/

Glossary


Tooling

GLuaLint

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

StyLua

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).

VS Code

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:

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.


Spacing

Spaces around operators

Good

local x = a * b + c

Bad

local x = a* b+c

No spaces inside parentheses and curly braces

The 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.

Spaces after commas

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})

Indentation should be done with tabs

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()
end

Bad

if cond then
		myFunc()
end

No spaces inside square brackets

Matches 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 ]

Single space after comment operators and before if not at start of line

The space keeps the comment visually separated from the code it documents.

Good

-- This is a good comment
local a = 3 -- This is also good

Bad

--This comment doesn't have a space before it
local a = 3-- This comment starts too close to the 3

Newlines

Never have more than 1 blank line

Good

local config = GM.Config

function GM:Think()
	-- do thing
end

Bad

local config = GM.Config


function GM:Think()
	-- do thing
end

Top level blocks should be separated by one newline

Separate 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
end

Bad, no visual separation between unrelated groups

local config = GM.Config
function GM:Think()
	-- do thing
end

A restating header like -- Think above function GM:Think() is a useless comment (see Comments) — rely on the blank line and a meaningful function name instead.

Non top level blocks/lines should never have more than 1 newline between them

Good

function test()
	local a = 3

	print(a)
end

Bad

function test()
	local a = 3


	print(a)
end

Returns should have one newline before them unless the codeblock is only one line

Good

function test()
	local a = 3

	return a
end

function test2()
	return 3
end

Bad

function test()
	local a = 3
	return a
end

Exception: 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
end

Code should be split into manageable chunks using a single new line

Good

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
end

Bad, 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
end

Gmod lua additions

Do not use GMod operators ( && || ! != )

Good

if a ~= b and not b then end

Bad, Garry's operators aren't recognized by standard Lua highlighting

if a != b && !b then end

Do not use GMod style comments ( /* */ and // )

Good

--[[
This line does stuff
]]
doStuff() -- Stuff being done

Bad, gmod comments aren't recognized by standard Lua highlighting

/*
This line does stuff
*/
doStuff() // Stuff being done

The use of continue is fine

GMod'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()
end

Avoid, 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
end

If 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)
end

Note

continue is error-prone inside repeat-until loops (see the wiki) - prefer a plain for/while loop there.

Hook return semantics

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(...)
end

If 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)
end

GMod 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.


File Structure and Realms

Load files in the right realm

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 client
  • lua/autorun/server/*.lua — server only
  • lua/autorun/client/*.lua — client only
  • everything else — wherever you include / AddCSLuaFile it

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 code

Good, 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")

One autorun file per addon

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")

Avoid massive files

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.

Protect server code

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.

Keep shared code thin

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.

Register net messages on the server, once

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")
end

Networking practices

Use the right write functions

Prefer 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.

Send references, not copies

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.

Rate-limit and validate client messages

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.

Don't write network state in per-frame hooks

Setting DataTable vars or sending net messages from Think, Move or other per-frame hooks floods the network. Update on change, not per frame.

Minimize client-to-server traffic

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.


Naming conventions

Local variables and functions should always be written in camelCase

Good

local myVariable = 10

Bad

local MyVariable = 10
local my_variable = 20

Constants should be written in SCREAMING_SNAKE

Constants are SCREAMING_SNAKE regardless of scope — local or global:

Good

CATEGORY_NAME = "nothing"
local MAXIMUM_VALUE = 25

Bad

CategoryName = "nothing"
categoryName = "nothing"
category_name = "nothing"
local maximumValue = 25

See also: Globals for where the line between a global constant and a mutable global runs.

Global variables should be written in PascalCase

Good

GlobalVariable = 10

Bad

globalVariable = 20
global_variable = 20

Global 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.

Methods and library functions should be written in PascalCase

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)
end

Bad

function classTable:setHealth(amount)
end

function someLib.resolveFilter(filter)
end

Table keys

Named 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 = 4

Bad, accessing the table value requires brackets and quotes because of the hyphen

myTable["my-value"] = 4

Use _ as a variable to "throwaway" values that will not be used

Good

for _, ply in ipairs(player.GetAll()) do
	local _, shouldKill = ply:GetKillData()

	if shouldKill then
		ply:Kill()
	end
end

Bad, 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 naming

  • 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-disconnect or even orgName/productName/cleanup-props-on-disconnect. But orgName/productName/cleanup-props would 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

General

Quotations

Always use double quotes. Escape inner double quotes with backslashes; never use single quotes

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!')

For strings with many quotes or line breaks, use long bracket strings

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\""

Do not use redundant parenthesis

Good

if x == y then

Bad, these parenthesis are not necessary

if (x == y) then

Redundant 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) + baseAmount

Unsavory, but fine

local amount = quantity * modifier + baseAmount

Prefer explicit comparisons when the value is guaranteed boolean

This 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 end

Grouped 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 end

Nil-safe check — for values that may be unset (nil and false both mean "not premium"):

if playerData.isPremium ~= true then return end

Also fine, not is idiomatic and reads like English

if not (IsValid(ply) and ply:Alive()) then return end

Note

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.

Multiline tables

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}

Multiline function calls

Multiline function calls should follow the same guidelines as Multiline tables

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})

Multiline tables passed as arguments

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,
})

Return early from functions

Good

function test()
	if not myThing then return end

	-- Do a bunch of real complicated things
end

Bad, adds an extra level of indentation for no reason

function test()
	if myThing then
		-- Do a bunch of real complicated things
	end
end

"Magic numbers" should be pulled out into meaningful variables

This 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
end

Bad, the significance of 25 is unknown without a meaningful variable name

function checkX(x)
	return x > 25
end

Complex expressions should be written on multiple lines with meaningful variable names

Good, 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 * age

Bad, this math is difficult to figure out from a glance

local width = (amount * 5) * (1 - lifetime / duration)

Write complex Lua patterns on multiple lines

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.)

Never use semicolons

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 a

Bad, this exclusively makes the code harder to read

local a = 3; print(a);

return a;

Make use of existing constants where possible

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)

Unnecessarily long conditions should be avoided

Conditions can be pulled out into meaningful variable names to avoid this

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
end

Bad, 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
end

Note

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
end

Bad, 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

Lines should be around 110 characters long at the most

~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)
end

Bad, 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) end

Numbers

Format numbers cleanly

Prefer 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

Do not add useless comments

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
end

Bad, 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
end

General Practices

Avoid unnecessary IsValid/:IsValid() checks

While 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)

Use varargs when calling print with multiple values

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")))
end

Good, 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")
	)
end

Use string.format when you need control over the output

Print 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()))

Avoid using tostring on numbers when concatenating

When concatenating a number to a string, the number is automatically coerced into a string

Good, relies on the built-in feature of Lua string concatenation

for i = 1, #lines do
	local line = lines[i]
	print("Line #" .. i, line)
end

Bad, 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 )
end

Prefer stable APIs over experimental ones

GMod 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)

Avoid deprecated APIs in new code

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()

Network variables are the exception, not the default

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.


Localize hot globals in performance-critical code

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.

Cache objects, don't construct them per frame

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)

Distance- and visibility-check world drawing

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 end

Use globals deliberately

Most addons need at least one global — the namespace. Follow these rules

1. Embed the organization name in the global name, e.g. ValvePrinters, RubatSkillsSystem — never generic names like Config or PlayerCount

2. Preferably a single global per addon — soft cap of 5

3. Keep addon data inside the namespace table instead of separate globals

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 MyAddon

Good, 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
end

Require only what you need

require 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")

Keep nesting depth under control

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)
end

Bad, 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
end

Choose the right iterator

ipairs 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])
end

Bad, pairs on a sequential table - 4x slower

for _, item in pairs(items) do
	processItem(item)
end

Prefer keyed lookup tables over table.HasValue

table.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!")
end

Bad, scans the table on every call

if table.HasValue({"admin", "superadmin"}, ply:GetUserGroup()) then
	ply:ChatPrint("Welcome!")
end

Don't loop over players or entities in Think hooks

player.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)

Cap tables that grow with unbounded input

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
end

Bad, grows forever

local logs = {}

local function addLog(entry)
	table.insert(logs, entry)
end

Code safety

Validate state, don't assume it

Never 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)

Entity removal and physics in touch/collision hooks

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)
end

Bad, can crash the server

function ENT:StartTouch(other)
	other:Remove()
end

Don't wrap code in pcall by default

A 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)

Use pcall where completion must be guaranteed

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
end

Never swallow what you catch

A 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.


Scale UI, don't hardcode sizes

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)

Credit borrowed code

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