Files
skills-public/code-languages/gsc.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

12 KiB

name, description
name description
gsc 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. 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 threaded 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.

// 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

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 threaded 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

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.