Scripts
Run cs_script (the game's server-side JavaScript) from a plugin — compiled assets, raw source, hot-reloaded files, inputs into a script and messages back out.
CS2 runs map and mode logic in JavaScript through point_script entities
(import { Instance } from "cs_script/point_script"). ISource2ModScripts lets a
plugin run scripts of its own and talk to them in both directions.
#include "source2mod/ISource2ModScripts.h"#include <source2mod/api> // the whole API, scripts.inc includedRunning a script
A script is known by a name you pick — it is also the targetname of its
point_script entity. Running a script under a name already in use replaces
the old one.
| Call | Runs | Needs a signature |
|---|---|---|
SCRIPTS_LOAD_ASSET(name, asset, persistent) | a compiled .vjs asset, exactly as a map's point_script does | no |
SCRIPTS_RUN_SOURCE(name, path, source, persistent) | raw JavaScript source you hand over | yes |
SCRIPTS_RUN_FILE(name, file, persistent) | a .js file from the server's disk, relative to game/csgo or absolute | yes |
Each returns the point_script entity's CEntityHandle, invalid on failure.
In SourcePawn the same calls are Source2ModScripts.LoadAsset(), RunSource()
and RunFile(), returning the CCSPointScriptEntity or NULL_ENTITY.
Raw source goes through the game's script loader, found through the gamedata
signature CSScript::RunScript; g_pSource2ModScripts->CanRunSource()
(Source2ModScripts.CanRunSource()) says whether it resolved. Compiled assets need nothing from the gamedata and keep
working through game updates.
static const char* s_source = R"js(
import { Instance } from "cs_script/point_script";
Instance.OnRoundStart(() => {
Instance.ServerCommand("source2mod_script myplugin " + encodeURIComponent("round started"));
});
Instance.OnScriptInput("ping", () => {
Instance.Msg("pong from cs_script");
});
)js";
SCRIPTS_RUN_SOURCE("myplugin", "scripts/myplugin.js", s_source, true);// No raw strings in SourcePawn: one literal per line, joined with "...".
char g_Source[] =
"import { Instance } from \"cs_script/point_script\";\n"
... "\n"
... "Instance.OnRoundStart(() => {\n"
... " Instance.ServerCommand(\"source2mod_script myplugin \" + encodeURIComponent(\"round started\"));\n"
... "});\n"
... "\n"
... "Instance.OnScriptInput(\"ping\", () => {\n"
... " Instance.Msg(\"pong from cs_script\");\n"
... "});\n";
public void OnPluginStart()
{
Source2ModScripts.RunSource("myplugin", "scripts/myplugin.js", g_Source, true);
}The path of RunSource only names the module in errors and stack traces.
A script that does not compile is reported by the game in the server console
("Exception during CompileModule …"); the entity exists regardless.
Hot reload
RunFile() reads the file again on every run, so after an edit:
g_pSource2ModScripts->Reload("myplugin"); // throws the old entity away and runs the file againSource2ModScripts.Reload("myplugin"); // throws the old entity away and runs the file againReload() works for every kind — source and assets simply run again as they
were given. A handy dev loop is a console command that calls it.
Persistent scripts
With persistent = true a script comes back by itself: when its entity is
removed (round restart, a map's cleanup, ent_remove) it is spawned again on
the next simulating frame, and after a map change on the new map's first one.
A non-persistent script is done once its entity goes. Remove(name) ends
either kind.
Native → script: inputs
SCRIPTS_FIRE_INPUT(name, input) (Source2ModScripts.FireInput()) fires the game's RunScriptInput entity
input, which calls the callback the script registered with
Instance.OnScriptInput(input, …):
SCRIPTS_FIRE_INPUT("myplugin", "ping");Source2ModScripts.FireInput("myplugin", "ping");Only the input name reaches the script. Keep data on the script side, or send it the other way.
Script → native: messages
A script sends a message with Instance.ServerCommand, through the hidden
source2mod_script command:
Instance.ServerCommand("source2mod_script <channel> " + encodeURIComponent(JSON.stringify(data)));and every plugin that hooked <channel> gets everything after the channel
name:
HOOK_SCRIPT_MESSAGE("myplugin", [](const char* pszChannel, const char* pszPayload)
{
// pszPayload is still URI-encoded, exactly as the script sent it.
META_CONPRINTF("[myplugin] script says: %s\n", pszPayload);
});public void OnPluginStart()
{
Source2ModScripts.HookScriptMessage("myplugin", OnScriptMessage);
}
public void OnScriptMessage(const char[] channel, const char[] payload)
{
// payload is still URI-encoded, exactly as the script sent it.
Source2ModApi.Log("[myplugin] script says: %s", payload);
}Several handlers may hook one channel, from one plugin or more; each gets every
message. HOOK_SCRIPT_MESSAGE returns a Source2ModHookId. To remove a handler,
pass that id to g_pSource2ModScripts->UnhookScriptMessage(id), or pass the same
function or object and method again with
UNHOOK_SCRIPT_MESSAGE(channel, handler). A lambda has no identity, so it can
only be removed by id. In SourcePawn Source2ModScripts.HookScriptMessage()
returns the id that Source2ModScripts.UnhookScriptMessage(id) takes. Whatever
your plugin still has hooked at unload is dropped for it.
The line goes through the server's command buffer: it arrives on a later
tick, a ; or a newline in it would split it into two commands, and it
is cut at the buffer's line limit. Encode the payload (as above) and keep it
short. Only the server itself can run source2mod_script — a client typing it,
or !source2mod_script in chat, is ignored.
Pitfalls
- The spawn warning. A script run from source spawns its
point_scriptwithout a compiled asset, and the game printsFailed to load cs_script ""while it spawns. It is harmless: the source goes into the same entity right after. - Lifetime. Scripts and message hooks belong to your plugin and are
removed when it unloads — nothing to undo in
Unload()/OnPluginEnd(). - Main thread only.