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>
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,switchfallthrough, or function pointers exist. A builtin from the wrong title is a silentundefinedor 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/printlnon 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, notlevel.queue.levelandselfare 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 leavesselfundefined and the firstself.xaccess errors. selfis not inherited into athreaded call's children automatically in the way callers expect —ent thread f()givesfself == ent, but a plainf()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, everylevel.*/self.*read you did not write in the same function, and every return value from a lookup.- Entities die. A handle held across a
waitmay be a dead entity by the time the thread resumes. Re-checkisdefined(ent)after everywait, not only before. isalive(ent)is the check for an actor you intend to act on;isdefinedonly 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.sizeis 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();
}
}
threadstarts a coroutine and returns immediately. It does not return the function's value — athreaded function'sreturnvalue 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 deadself— or leaks, one copy per spawn.level endon("game_ended")plus the relevant entityendonis the baseline. endonmust be registered before the firstwait, at the top of the function. Registering it later leaves a window.waittillis 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
notifywith 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.waittillframeendis the tool for "after everything else this frame" — use it instead ofwait 0.05when the intent is ordering, not delay.wait 0is not zero; it is one frame.wait 0.05is 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
waitinside. Afor(;;)with nowaithangs 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
precachemodelafter 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.sizecounts keys. Rebuild rather than delete-in-place. - String comparison is
==; there is noswitchon anything but constants, and fallthrough rules differ by dialect. - Vectors are
(x, y, z)literals with+,-, and scalar*. Component access isv[0],v[1],v[2].anglestoforward/vectornormalize/distanceare 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\_utilityandcommon_scripts\utilityalready 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 = 999to 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. setdvaris 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.