Files
skills-public/code-languages/lua.md
T
BluscreamandClaude Opus 5 6207de1a97 docs(code-languages): add Lua and GSC skills
Lua covers dialect pinning (5.1/LuaJIT/5.2/5.3+/HavokScript), luac -p and
luacheck as the only real gate for an embedded host, locals over globals,
pcall at the boundary, the #t / pairs / pattern traps, and the sandboxed-host
rules including Call of Duty LUI specifics.

GSC covers the absence of any compiler, linter, or test harness and what to
do instead, scope prefixing on level/self, isdefined on every external read,
endon before the first wait, the per-frame instruction budget, load-phase
precaching, and side effects on shared level state.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-09 02:28:16 +02:00

11 KiB

name, description
name description
lua Use this skill when writing, reviewing, or debugging Lua — including Lua 5.1/5.2/5.3+ and LuaJIT dialect differences, HavokScript (HKS) as used by Call of Duty's LUI, luacheck configuration, locals over globals, pcall error handling, the integer-vs-float and table-length pitfalls, module layout, and embedded/sandboxed-host constraints.

Lua — Rules & Preferences

Extends coding-dev.md. Everything there applies; this adds Lua specifics.

Lua is almost always embedded: the host decides the dialect, the available standard library, the module loader, and whether an uncaught error takes the process down. Establish which host and which dialect you are writing for before writing a line — the same file is correct in one and a crash in another.


1. Pin the Dialect First

Dialect Notable traits
5.1 loadstring, unpack, setfenv/getfenv, module(), no integer type, no goto
LuaJIT 5.1 plus bit, ffi, jit; a few 5.2 extensions
5.2 load replaces loadstring, _ENV replaces setfenv, goto, table.unpack
5.3 distinct integer subtype, //, bitwise operators, math.type
5.4 <const> / <close> attributes, generational GC
HavokScript (HKS) a 5.1 fork; see §8
  • Determine it from the host, not from habit. print(_VERSION) in the target runtime, or read the host's embedding code. A goto in a 5.1 host is a parse error, and a loadstring in 5.4 is a nil call.
  • Write for the host's dialect; do not write portable-across-all-five code unless the module genuinely ships to several. Compatibility shims (local unpack = table.unpack or unpack) are worth it only where that is true.
  • Lua 5.1 has no integer type — every number is a double. 1/2 is 0.5, and tostring(3.0) is "3" in 5.1 but "3.0" in 5.3. Never round-trip a number through a string and expect the same text across dialects.

2. The Gate

luacheck .                        # lint; must be clean
stylua --check .                  # or lua-format; formatting
luac -p $(git ls-files '*.lua')   # parse check against the TARGET dialect's luac
busted                            # tests, where the code is testable off-host
  • luac -p is the cheapest real check available and the one most often skipped. It catches syntax errors, and when run with the host's own Lua version it catches dialect mismatches before the host does. For an embedded host whose scripts cannot be unit tested, this plus luacheck is the gate — wire it into the build script and never commit a file that fails it.
  • luacheck needs its globals declared or it reports the host's API as undefined. Declare them rather than disabling the check:
    -- .luacheckrc
    std = "lua51"
    read_globals = { "Engine", "LUI", "GenericButtonSettings" }
    globals = { "MyModuleState" }   -- only what this code deliberately writes
    max_line_length = 120
    
  • read_globals for the host's API, globals only for names you intentionally define. If the list of writable globals is growing, that is the finding — see §3.

3. Locals, Not Globals

Every variable is local unless it must be visible to the host. An unqualified assignment creates a global, silently, with no warning from the language.

-- WRONG: typo creates a new global; the real one keeps its old value
local function update() cofnig.volume = 1 end

-- RIGHT
local config = require("config")
local function update() config.volume = 1 end
  • Locals are also faster — they are register slots, while a global is a hash lookup in _ENV on every access. Hoist hot library functions into locals at the top of a module: local floor, insert = math.floor, table.insert.
  • Namespace what must be global. One table (MyMod.state), never a scatter of myModFoo / myModBar. In a shared host every global is in one namespace with every other script's.
  • luacheck flags accidental globals. Keep it clean and the typo class disappears.
  • The 200-local-per-function limit is real; hitting it means the function needed splitting long ago.

4. Errors — pcall at the Boundary, Not Everywhere

  • error() for real failures; return nil, message for expected ones. A file that may not exist returns nil, err; a caller passing the wrong type gets an error.
  • pcall / xpcall at the boundary only — a callback the host invokes, a plugin entry point, a request handler. Wrapping every internal call turns a stack trace into a string and hides where the fault was.
  • xpcall(f, debug.traceback) when you need the trace. Plain pcall discards it, and the message alone rarely identifies the line in a long callback chain.
  • Never swallow it. pcall(f) with the result ignored is an empty catch block:
    -- WRONG
    pcall(risky)
    
    -- RIGHT
    local ok, err = pcall(risky)
    if not ok then log.warn("risky failed: " .. tostring(err)) end
    
  • error("msg", 2) blames the caller instead of the raising line — the right level for an argument-validation error.
  • error() can be given a table, which is how you carry structured error data; the tostring of it is then meaningless, so handlers must check type(err).

5. Tables — The Traps

  • #t is only defined for a sequence (keys 1..n with no holes). With a hole it may return either boundary, and the choice can change between runs. Track the count yourself when a table can be sparse, and never use #t on a map.
  • table.insert / table.remove shift every later element — O(n). Appending with t[#t+1] = v is cheaper than table.insert(t, v); building a new table beats removing from the middle in a loop.
  • Never mutate a table while iterating it with pairs. Assigning to an existing key is defined; adding a new one is not. Collect keys first, then act.
  • pairs order is unspecified and varies run to run. Anything user-visible or compared between runs must sort its keys first.
  • ipairs stops at the first nil. Use it for sequences and pairs for maps — the choice documents which the table is.
  • nil cannot be stored in a table; t[k] = nil is a delete, and a sequence containing nil is not a sequence.
  • Beware 0 and "" being truthy. Only nil and false are false, so if count then is true at zero — test if count ~= 0 then.
  • 1 and 1.0 are the same key in 5.3+ (the float is normalized), but a non-integral float is its own key.

6. Strings

  • Never build a string in a loop with ... Each concatenation allocates; collect into a table and table.concat once.
  • Lua patterns are not regex. % is the escape (not \), there is no alternation and no +/? on groups, character classes are %a %d %s %w %p %c %x and their uppercase complements. Reach for lpeg when the grammar is real.
  • string.format("%q", s) produces a Lua-readable quoted literal — correct for serialization, wrong for display.
  • %s in string.format calls tostring, so a nil argument renders "nil" rather than erroring — the opposite of .., which errors on nil. That asymmetry is a frequent crash source in host callbacks: prefer string.format over .. for any message built from values that could be nil, or guard with tostring().
  • Indices are 1-based and inclusive on both ends: s:sub(1, 3) is the first three bytes.
  • Strings are byte strings. #s and s:sub count bytes, not characters — use utf8 (5.3+) or a library for anything non-ASCII.

7. Modules & Structure

local M = {}

local function helper(x)   -- private: a local, not M.helper
    return x * 2
end

function M.public(x)
    return helper(x)
end

return M
  • Return a local table. Never set globals, never use module() (deprecated in 5.1, removed in 5.2).
  • require caches by module name — a module body runs once. Do not rely on re-require to reset state; expose an explicit M.reset() if a reset is needed.
  • : for methods that take self, . for everything else. Be consistent — a . call on a : method passes the first argument as self and fails confusingly.
  • File and function size limits from the general rules apply.

8. Embedded & Sandboxed Hosts

When the host owns the interpreter (a game engine, nginx, Redis, Neovim, a plugin API), its constraints outrank every preference above.

  • Verify an API exists before calling it. The host's surface differs between versions and builds, and a missing function is a nil call that takes down whatever invoked you:
    if type(Engine.SetDvarString) == "function" then
        Engine.SetDvarString(name, value)
    end
    
    Prefer type(x) == "function" over a bare if x then — it also rejects a truthy non-callable.
  • An uncaught error in a host callback may crash the process, not just the script. In a C host the error unwinds through C frames that were not written to expect it. Treat every callback body as a trust boundary and pcall it.
  • Do not monkey-patch host globals (print, require, a host API function) outside a tightly scoped save/restore — and even then, remember the restore does not happen if the body errors, so do it in the pcall result path. Reentrancy makes the saved original a wrapper.
  • Load order is the host's, not yours. Do not assume a sibling script has run. Read shared state inside callbacks rather than at load time, and register your own state defensively.
  • Check what the standard library actually is. Many hosts remove io, os, and debug, or replace them with their own (io.readfile rather than io.open). Feature-test; do not assume.
  • Logging is whatever the host provides. Use it rather than print, and never leave a high-frequency print in a per-frame callback.

HavokScript (HKS) — Call of Duty LUI

Call of Duty's UI (LUI) runs HavokScript, a proprietary Lua 5.1 fork:

  • It is 5.1-based: loadstring, unpack, setfenv — no goto, no integer division, no bitwise operators, no table.unpack.
  • There is no require for siblings inside a UI script folder in h2-mod-style loaders; a module directory's __init__.lua is loaded on its own. Keep each module self-contained in one file rather than splitting it and discovering the split does not load.
  • There is no off-host test harness. luac -p with a 5.1 luac, luacheck --std lua51, and reading the game's console output are the entire verification story — so lint and parse checks are not optional here, they are the only gate that exists.
  • A dvar read is not type-safe. Engine.GetDvarString on an unregistered dvar returns nil, and nil .. "text" is an error that propagates out of the LUI event dispatcher as an access violation. Register the dvar first, feature-test the accessor, and build messages with string.format.
  • Hot-reload with dofile leaves the previous registration in place. Guard re-entry with a module flag, and expect duplicate menu buttons when you do not.