--- 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, block scoping, and compiling off-engine with gsc-tool. --- # 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 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. ```bash gsc-tool --mode comp --game --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 `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. - **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.