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>
This commit is contained in:
BluscreamandClaude Opus 5 committed 2026-10-09 02:28:16 +02:00
1 parent b8505fc623
commit 6207de1a97
4 files changed
+473

No files matched your search

+2
View File
@@ -24,6 +24,8 @@ A collection of public, reusable agent skills, cheatsheets, and operational work
| **Go** | [`go.md`](skills-public/code-languages/go.md) | strict `golangci-lint` set, concrete types over `interface{}`, context everywhere, CGO-free static builds |
| **C# / .NET** | [`csharp-dotnet.md`](skills-public/code-languages/csharp-dotnet.md) | `TreatWarningsAsErrors`, nullable enabled, no `.Result`, trimming/single-file publish caveats, SDK shims |
| **C / C++** | [`c-cpp.md`](skills-public/code-languages/c-cpp.md) | full warning set + `-Werror`, sanitizers, RAII/Rule of Zero, target-based CMake, FFI ownership |
| **Lua** | [`lua.md`](skills-public/code-languages/lua.md) | pin the dialect first, `luac -p` + luacheck as the only gate, locals over globals, `pcall` at the boundary, `#t`/pattern traps, embedded & HavokScript hosts |
| **GSC** | [`gsc.md`](skills-public/code-languages/gsc.md) | no compiler or test harness, `isdefined` on everything external, `endon` before the first wait, per-frame budget, precache at load, level-state side effects |
Each language file extends `coding-dev.md` rather than repeating it. A project's own
`AGENTS.md` / `CLAUDE.md` / `.rules` always overrides both.
+237
View File
@@ -0,0 +1,237 @@
---
name: gsc
description: >-
Use this skill when writing, reviewing, or debugging GSC (Game Script / Call of Duty script)
or CSC — including entity-vs-level scope, threads and notify/wait semantics, endon/waittill
discipline, undefined-value guards, the no-OOP/no-exceptions reality, asset precaching,
per-frame cost, and the fact that GSC has no compiler, linter, or test harness worth the name.
---
# GSC — Rules & Preferences
Extends [`coding-dev.md`](skills-public/coding-dev.md). Everything there applies; this adds GSC specifics.
GSC is the Call of Duty engine's own scripting language (`.gsc` server-side, `.csc`
client-side). It is **not Lua, not JavaScript, and not C** despite resembling all three. It has
no type system, no exceptions, no classes, no closures in most dialects, and — in a modded
install — **no diagnostics beyond the in-game console**. That last fact drives most of what
follows: the language will not tell you that you are wrong, so the code has to be written so
that it cannot be.
> [!IMPORTANT]
> **Establish the dialect before writing anything.** IW3/IW4 (MW/MW2), IW5 (MW3), T5/T6 (Black
> Ops 1/2), IW6, s1/s2, H1/H2 (MWR / MW2CR), and the various mod loaders (h2-mod, iw4x, plutonium,
> t6 GSC Studio) each differ in available builtins, in how scripts get loaded, and in whether
> `foreach`, `switch` fallthrough, or function pointers exist. A builtin from the wrong title is
> a silent `undefined` or a hard crash.
---
## 1. There Is No Gate — Build One
GSC has no `gsc -Wall`. What exists:
| Check | How |
| :--- | :--- |
| **Syntax** | The engine's own compile at load. Read the console; a parse error names the file and line and then stops the whole script loading. |
| **Runtime errors** | In-game console / `developer 1`. Many mod loaders print a script stack trace. |
| **Static lint** | Third-party only, and dialect-specific. Use one if it exists for the target title; otherwise there is none. |
| **Tests** | None off-host. Behaviour is verified by playing the map. |
Therefore:
- **`developer 1` (or the loader's equivalent) is mandatory during development.** Without it the
engine suppresses script warnings and an undefined-value error looks like "the feature did
nothing".
- **Load the script and read the console on every change**, before judging behaviour. "Nothing
happened" is far more often a parse error at load than a logic bug.
- **Instrument with `iprintln` / `iprintlnbold` / `println`** on the success *and* the failure
path. A function that silently returns on a guard is indistinguishable from one that never ran.
- **Say plainly in your report that GSC behaviour is unverified** unless you actually loaded the
map and watched it. There is no test suite to hide behind.
---
## 2. Scope — `self`, `level`, `game`, and Locals
| Scope | Lifetime | Use for |
| :--- | :--- | :--- |
| local variable | the function call | everything it can hold |
| `self` | the entity | per-entity state (a player's, an actor's) |
| `level` | the current map/level | per-round state, map config, shared handles |
| `game` | across map restarts | persistent state only |
- **Prefix your own fields.** `level.mymod_spawn_queue`, not `level.queue`. `level` and `self`
are one flat namespace shared with the stock game scripts and every other mod; a collision
overwrites engine state and the symptom appears somewhere unrelated.
- **Never write to a stock field you did not create** unless that is the documented integration
point. Read it, copy it, leave it.
- **A function called *on* an entity gets it as `self`**: `player giveweapon("m4")`. Calling the
same function without a prefix leaves `self` undefined and the first `self.x` access errors.
- `self` is not inherited into a `thread`ed call's children automatically in the way callers
expect — `ent thread f()` gives `f` `self == ent`, but a plain `f()` inside it does not.
---
## 3. Undefined Is The Default — Guard Everything External
GSC has no types and no nullability. **Reading a field that was never set yields `undefined`,
and using it in arithmetic or a method call is a runtime error that aborts the thread** — often
silently from the player's point of view.
```gsc
// WRONG: any of these can be undefined
ent = level.target;
ent setorigin(level.player.origin + (0, 0, 64));
// RIGHT
if (!isdefined(level.target) || !isdefined(level.player))
return;
```
- **`isdefined()` on every entity, every `level.*`/`self.*` read you did not write in the same
function, and every return value from a lookup.**
- **Entities die.** A handle held across a `wait` may be a dead entity by the time the thread
resumes. Re-check `isdefined(ent)` *after* every `wait`, not only before.
- `isalive(ent)` is the check for an actor you intend to act on; `isdefined` only proves the
handle exists.
- This is the one place where the general "don't over-guard" rule inverts: in GSC a value from
outside the current function genuinely can be undefined, so the check is not dead code.
- Arrays: `array.size` is safe on an empty array but errors on an undefined one. Guard the array,
then the index.
---
## 4. Threads, `notify`, `waittill`, `endon`
```gsc
monitor_thing()
{
level endon("game_ended");
self endon("death");
for (;;)
{
self waittill("trigger_activated");
do_work();
}
}
```
- **`thread` starts a coroutine and returns immediately.** It does not return the function's
value — a `thread`ed function's `return` value is unreachable. Pass a handle or notify.
- **Every long-lived thread needs an `endon`.** Without one, a loop keeps running after the
entity dies or the level ends, and then errors on a dead `self` — or leaks, one copy per spawn.
`level endon("game_ended")` plus the relevant entity `endon` is the baseline.
- **`endon` must be registered before the first `wait`**, at the top of the function. Registering
it later leaves a window.
- **`waittill` is the event primitive — prefer it to polling.** `self waittill("death")` is
correct; `while (isalive(self)) wait 0.05;` is the same thing, worse, and costs every frame
forever. This is the general events-over-polling rule, and GSC gives you the event.
- **A `notify` with no waiter is lost.** There is no queue. If a thread might not be waiting yet,
set a flag as well as notifying, and have the waiter check the flag first.
- `waittill("x", a, b)` receives the notify's extra parameters. The names are positional.
- **`waittillframeend`** is the tool for "after everything else this frame" — use it instead of
`wait 0.05` when the intent is ordering, not delay.
- `wait 0` is not zero; it is one frame. `wait 0.05` is one server frame at 20Hz — write the
intent, not a magic number, and name the constant.
---
## 5. Per-Frame Cost Is Your Budget
The script VM shares the server frame with everything else, and there is a **hard instruction
budget per frame**. Exceeding it produces `script runtime error: exceeded maximum number of
script variables` or a visible server hitch.
- **One polling loop per concern, never one per entity.** A `for(;;) { wait 0.05 }` loop on each
of 60 actors is 60 wakeups a frame. Keep one manager loop over an array.
- **Where an event exists, the polling loop should not.** See §4.
- **Never loop without a `wait` inside.** A `for(;;)` with no `wait` hangs the server frame and
takes the match down — this is the single most common fatal GSC mistake.
- Keep the body of a per-frame loop trivial. Move anything expensive behind a condition or a
longer interval, and say in a comment why that interval.
- Spawning is expensive and entity counts are capped. Reuse and pool; delete what you spawned.
---
## 6. Assets Must Be Precached
```gsc
init()
{
precachemodel("com_laptop_open");
precacheshader("hud_icon_frag");
precachestring(&"MYMOD_HINT");
}
```
- **Precaching only works during the level's load phase.** A `precachemodel` after the map is
live either errors or silently fails, and the model then renders as a missing-asset
placeholder or nothing at all.
- Put every precache in the init path, unconditionally — not behind the feature flag that decides
whether the asset gets used.
- **A model or weapon name not present in the loaded level's zone will not spawn**, regardless of
precaching. Asset availability is per-map; a name verified on one map proves nothing about
another. State which map you verified on.
- Localized strings use the `&"REFERENCE"` form and must exist in the string table; a missing one
displays the raw reference.
---
## 7. Language Facts That Surprise People
- **No exceptions, no `try`.** An error aborts the current thread and nothing else. There is no
recovery and no handler — which is why §3's guards are the error handling.
- **No classes, no inheritance, no `new`.** "Objects" are entities with fields, or plain arrays
used as structs. `spawnstruct()` is the idiom for a bag of fields with no world presence.
- **Function pointers**: `::funcname` (and `[[ptr]]()` to call) exist in most dialects but not
all. Verify before relying on them.
- **No closures** in most dialects — an inner function cannot capture a local. Pass state
explicitly or hang it on a struct.
- `foreach (x in arr)` exists in IW4+ and T6 but not in the earliest dialects; `for (i = 0;
i < arr.size; i++)` always works.
- **Arrays are associative.** `array[]` with integer keys behaves like a list, but deleting an
element leaves a hole and `.size` counts keys. Rebuild rather than delete-in-place.
- String comparison is `==`; there is no `switch` on anything but constants, and fallthrough
rules differ by dialect.
- Vectors are `(x, y, z)` literals with `+`, `-`, and scalar `*`. Component access is `v[0]`,
`v[1]`, `v[2]`. `anglestoforward` / `vectornormalize` / `distance` are builtins — do not
hand-roll vector maths.
---
## 8. Style & Structure
- **Allman braces, 4 spaces** — the stock scripts' convention; match it, because GSC is almost
always read next to engine scripts.
- `main()` is the level entry point; `init()` is the convention for a module's setup called from
it. Which one the loader calls is dialect- and loader-specific — verify, do not assume.
- `#include maps\_utility;` (dialect-dependent path syntax) at the top; prefer the stock utility
functions over reimplementing them. `maps\_utility` and `common_scripts\utility` already have
flag, wait, and array helpers.
- **`/* */` comments do not nest.** Commenting out a block that contains one breaks the file.
- File and function size limits from the general rules apply, and matter more here than usual:
there is no debugger worth the name, so a 300-line function is unanalysable.
- **Delete dead code.** With no linter to flag it, commented-out GSC accumulates forever.
---
## 9. Side Effects on Level State
GSC runs inside someone else's running game. A change to shared state is not scoped to your
feature and is not undone when your thread ends.
- **Mutating a stock spawner, trigger, or flag changes the mission.** Setting
`spawner.count = 999` to get an extra AI makes that spawner infinite for the rest of the level.
Save the old value and restore it, or clone rather than mutate.
- **Anything you spawn, you delete.** Track handles in your own `level.mymod_*` array and provide
a cleanup path; an orphaned entity survives until map change and counts against the cap.
- **`setdvar` is global and persistent.** A dvar used as a command channel must be cleared
immediately on read, and the clear must happen even on the failure path — otherwise the next
poll re-fires the same command.
- A dvar as an IPC channel is a **poll**, which §5 says to avoid; it is acceptable only because
GSC cannot receive a host event, and the interval and the clear-on-read should both be
explicit and commented.
- Never leave the player in a modified state (god mode, `notarget`, a changed movement speed)
when the feature that set it is turned off. Toggles need an off path that actually restores.
+232
View File
@@ -0,0 +1,232 @@
---
name: lua
description: >-
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`](skills-public/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
```bash
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:
```lua
-- .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.
```lua
-- 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:
```lua
-- 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
```lua
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:
```lua
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.
+2
View File
@@ -38,6 +38,8 @@ Per-language specifics live in [`code-languages/`](skills-public/code-languages/
| Go | [`code-languages/go.md`](skills-public/code-languages/go.md) |
| C# / .NET | [`code-languages/csharp-dotnet.md`](skills-public/code-languages/csharp-dotnet.md) |
| C / C++ | [`code-languages/c-cpp.md`](skills-public/code-languages/c-cpp.md) |
| Lua | [`code-languages/lua.md`](skills-public/code-languages/lua.md) |
| GSC (Call of Duty script) | [`code-languages/gsc.md`](skills-public/code-languages/gsc.md) |
---