Files
BluscreamandClaude Opus 5 e385e01993 fix(gsc): correct the claim that no off-engine compiler exists
gsc-tool compiles GSC outside the engine and reproduces the engine's own
diagnostics, so the skill's "no compiler, linter, or test harness worth the
name" framing was wrong and sent the reader to load a map instead of running a
50ms check. §1 is now the gate: `--mode comp --game <title> --system pc --dry`,
with the warning that `--mode parse` is syntax-only and accepts code the
compiler rejects, and that `--game` takes the engine codename (h2 for MW2CR)
rather than the marketing name.

A clean compile proves the script loads, not that it works, so the
"behaviour is unverified until the map runs" half is kept and sharpened.

Also adds two §8 entries found the hard way: a local declared inside a block is
not visible after it, and there are no file-scope constants.

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

13 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, block scoping, and compiling off-engine with gsc-tool.

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 no test harness. It can be compiled off-engine (see §1), which catches syntax and scope errors — but nothing checks a type, a field that was never set, or a builtin that does not exist in the target title. So the code still has to be written so that it cannot be wrong.

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. The Gate — gsc-tool, Then The Game

There is an off-engine GSC compiler: gsc-tool. Reach for it first; it is easy to assume none exists and verify by loading a map instead.

gsc-tool --mode comp --game <title> --system pc --dry path/to/script.gsc

It exits non-zero and prints the engine's own diagnostic, e.g. [ERROR]:compiler:script:97:25: local variable 'previous_count' not found.

  • Use --mode comp, not --mode parse. parse is syntax-only and happily accepts code the engine's compiler rejects, including the block-scope error above. comp with --dry writes nothing and does the real thing.
  • --game must match the title, and it is not the marketing name: MW2 Campaign Remastered is h2, MWR is h1. The choices are iw5 iw6 iw7 iw8 iw9 s1 s2 s4 h1 h2 t6 t7 t8 t9 jup. Wrong title, wrong builtin table.
  • Prebuilt binaries ship on its GitHub releases; there is no distro package.
  • Many mod trees already vendor it (Overlord has it under deps/gsc-tool).

What that still leaves:

Check How
Syntax + semantics gsc-tool --mode comp. Put it in the build gate.
Builtin exists for this title gsc-tool's own engine tables, e.g. src/gsc/engine/h2_meth.cpp. Grep before using a builtin you remember from another title.
Runtime errors In-game console / developer 1. Many mod loaders print a script stack trace.
Tests None off-host. Behaviour is verified by playing the map.

So a compile is cheap and mandatory, and behaviour is still unverified until the map runs. Both halves matter:

  • 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. A clean gsc-tool compile proves the script loads, not that it works.

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.
  • A local declared inside a block is not visible after it. Writing it in one if and reading it in a second does not compile — declare it unconditionally at function scope. Assigning undefined to an entity field unsets it, which is usually the right "restore nothing".
  • No file-scope constants. MY_CONST = 0.2; outside a function is not valid in most dialects; use a local at the point of use or a level. field set during init.
  • 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.