Coming from SourceMod
s2script’s APIs follow SourceMod’s shape on purpose. The forwards, admin flags, HookResult values and SDKHook types keep their SourceMod names, so most of what you know carries over. The main changes: you write TypeScript instead of SourcePawn, and you get typed objects instead of handles. Forwards become named exported functions. You build a .s2sp archive instead of compiling a .smx, and a replaced archive hot-reloads.
Concept map#
| SourceMod | s2script |
|---|---|
.sp → spcomp → .smx | npm package → s2s build → .s2sp (Authoring) |
addons/sourcemod/plugins/, sm plugins reload | addons/s2script/plugins/: drop to load, replace to hot-reload, delete to unload (Authoring) |
public Plugin myinfo | package.json name / version (Authoring) |
OnPluginStart / OnPluginEnd / OnMapStart / OnMapEnd | Exported functions with the same names (Lifecycle) |
OnClientPutInServer / OnClientPostAdminCheck / OnClientDisconnect | Same names; they receive a Client handle, not an index (Lifecycle) |
OnGameFrame, OnEntityCreated, OnPlayerRunCmd | Same-name exported functions (Lifecycle) |
RegConsoleCmd / RegAdminCmd / RegServerCmd | command / command.admin / command.server (Commands) |
AddCommandListener | command.onClientCommand (commands module) |
GetCmdArgs / GetCmdArg / GetCmdArgString | cmd.argCount / cmd.arg(n) (0-based) / cmd.argString |
ReplyToCommand | cmd.reply (commands module) |
Plugin_Continue / Plugin_Changed / Plugin_Handled / Plugin_Stop | HookResult.Continue / Changed / Handled / Stop (events module) |
ADMFLAG_KICK, ADMFLAG_SLAY, … | ADMFLAG.KICK, ADMFLAG.SLAY, … (same bit values) (admin module) |
CheckCommandAccess, CanUserTarget | Admin.forSlot(slot)?.hasFlags(…), Admin.canTarget (admin module) |
ProcessTargetString / FindTarget | Player.target(pattern, callerSlot) (cs2 module) |
GetClientOfUserId | Player.fromUserId, or ev.getPlayerSlot('userid') inside an event |
HookEvent (post / EventHookMode_Pre) | hook.on / hook.onPre (Events) |
FireEvent, FireToClient | Events.fire, Events.fireToClient (events module) |
HookEntityOutput | onOutput (Entities) |
CreateTimer / TIMER_REPEAT | after / every, or await delay(ms) (Async) |
GetEntProp / SetEntProp (m_iHealth, …) | Generated schema accessors: pawn.health, wrapEntity(…) (Schema fields) |
CreateEntityByName / DispatchSpawn / TeleportEntity / AcceptEntityInput | createEntity / ref.spawn() / ref.teleport / ref.acceptInput (entity module) |
SDKHook(client, SDKHook_OnTakeDamage, cb) | SDKHook(entity, SDKHookType.OnTakeDamage, cb) (SDKHooks) |
CreateConVar / HookConVarChange / SetConVarString | Server.registerCvar / Server.onCvarChange / Server.setCvar (server module) |
AutoExecConfig + cfg/sourcemod/*.cfg | s2script.config in package.json + configs/<plugin-id>.json (Config) |
PrintToChat / PrintToChatAll | Chat.toSlot or client.chat / Chat.toAll (chat module) |
LoadTranslations / %t / %T | translations.load / cmd.replyT / Translations.translate (Translations) |
CreateNative / CreateGlobalForward | publish with contract methods / forwards (Interfaces) |
OnLibraryAdded / OnLibraryRemoved | watchOptional (Interfaces) |
RegClientCookie / GetClientCookie / SetClientCookie | Cookies.register / Cookies.get / Cookies.set (Cookies) |
SQL_TConnect / Database.Connect | await Database.open(name) (Async) |
Menu / VoteMenu | Menu / Vote.start (menu module, votes module) |
KeyValues | No equivalent. Use JSON with config.readFile / config.writeFile (Config) |
| basecommands, basechat, basebans, adminmenu, … | Ship in the release zip as .s2sp plugins (Getting started, catalog) |
The release includes the SourceMod base suite: basecommands, basechat, playercommands, antiflood, adminhelp, basecomm, basebans, reservedslots, basetriggers, funcommands, clientprefs, adminmenu and basevotes, plus zones. nominations, rockthevote, nextmap and funvotes are opt-in and ship under plugins/disabled/.
Side by side#
Plugin skeleton#
#include <sourcemod>
public Plugin myinfo = {
name = "Hello",
author = "me",
description = "Says hello",
version = "1.0.0",
url = ""
};
public void OnPluginStart()
{
RegConsoleCmd("sm_hello", Command_Hello);
}
public Action Command_Hello(int client, int args)
{
ReplyToCommand(client, "Hello!");
return Plugin_Handled;
}import { command, HookResult } from '@s2script/sdk';
export function OnPluginStart(): void {
command('sm_hello', (cmd) => {
cmd.reply('Hello!');
return HookResult.Handled;
});
}Plugin metadata goes in package.json. The host looks up OnPluginStart and the other forwards by name on your module’s exports. You don’t need a public keyword or a callback name string. Register commands, hooks and translations inside OnPluginStart. Registering them at module top level throws.
Admin command with arguments and entity props#
public void OnPluginStart()
{
RegAdminCmd("sm_addhp", Command_AddHp, ADMFLAG_SLAY, "sm_addhp <#userid|name> <amount>");
}
public Action Command_AddHp(int client, int args)
{
if (args < 2)
{
ReplyToCommand(client, "Usage: sm_addhp <#userid|name> <amount>");
return Plugin_Handled;
}
char pattern[64];
GetCmdArg(1, pattern, sizeof(pattern));
int amount = GetCmdArgInt(2);
int target = FindTarget(client, pattern);
if (target == -1)
return Plugin_Handled;
int hp = GetEntProp(target, Prop_Send, "m_iHealth");
SetEntProp(target, Prop_Send, "m_iHealth", hp + amount);
ReplyToCommand(client, "%N now has %d HP.", target, hp + amount);
return Plugin_Handled;
}import { command, ADMFLAG, HookResult } from '@s2script/sdk';
import { Player } from '@s2script/cs2';
export function OnPluginStart(): void {
command.admin('sm_addhp', ADMFLAG.SLAY, (cmd) => {
if (cmd.argCount < 2) {
cmd.reply('Usage: sm_addhp <#userid|name> <amount>');
return HookResult.Handled;
}
const targets = Player.target(cmd.arg(0), cmd.callerSlot, true);
const amount = cmd.argInt(1);
if (targets.length === 0) {
cmd.reply('No matching player.');
return HookResult.Handled;
}
for (const player of targets) {
const pawn = player.pawn;
const hp = pawn?.health;
if (pawn && hp != null) pawn.health = hp + amount;
}
cmd.reply(`Added ${amount} HP to ${targets.length} player(s).`);
return HookResult.Handled;
});
}Arguments are 0-based. GetCmdArg(1) becomes cmd.arg(0). cmd.argInt and cmd.argFloat replace the buffer-and-convert step.
In CS2, health lives on the pawn (the in-world body), not the controller. Fields are generated accessors, so a typo is a compile error instead of a runtime prop lookup failure. Reads return null on a stale entity, and writes send the network update for you. For entities without a wrapper, use wrapEntity('CBaseModelEntity', ref). See Schema fields.
Event hooks, post and pre#
public void OnPluginStart()
{
HookEvent("player_death", Event_PlayerDeath);
HookEvent("player_team", Event_PlayerTeam, EventHookMode_Pre);
}
public void Event_PlayerDeath(Event event, const char[] name, bool dontBroadcast)
{
int attacker = GetClientOfUserId(event.GetInt("attacker"));
if (attacker > 0)
PrintToChat(attacker, "Nice shot.");
}
public Action Event_PlayerTeam(Event event, const char[] name, bool dontBroadcast)
{
return Plugin_Handled;
}import { hook, Chat, HookResult } from '@s2script/sdk';
export function OnPluginStart(): void {
hook.on('player_death', (ev) => {
const attacker = ev.getPlayerSlot('attacker');
if (attacker >= 0) Chat.toSlot(attacker, 'Nice shot.');
});
hook.onPre('player_team', () => HookResult.Handled);
}The GameEvent is valid only during the synchronous handler. Read the fields you need before any await. For typed field names, use Events.on from @s2script/cs2.
Repeating timer#
int g_iShown;
public void OnPluginStart()
{
CreateTimer(60.0, Timer_Advert, _, TIMER_REPEAT);
}
public Action Timer_Advert(Handle timer)
{
PrintToChatAll("Visit example.com");
if (++g_iShown >= 10)
return Plugin_Stop;
return Plugin_Continue;
}import { Chat } from '@s2script/sdk';
import { every } from '@s2script/sdk/timers';
export function OnPluginStart(): void {
let shown = 0;
const advert = every(60_000, () => {
Chat.toAll('Visit example.com');
if (++shown >= 10) advert.kill();
});
}Intervals are in milliseconds, not seconds. Stop a timer with timer.kill() instead of returning Plugin_Stop. after(ms, fn) is the one-shot form. In async code, await delay(ms) is often simpler. Timers belong to your plugin and are killed on unload, so there’s no handle to close. There is no TIMER_FLAG_NO_MAPCHANGE. If a timer shouldn’t outlive the map, kill it in OnMapEnd.
ConVars and config#
ConVar g_cvEnabled;
public void OnPluginStart()
{
g_cvEnabled = CreateConVar("sm_greet_enabled", "1", "Greet players", _, true, 0.0, true, 1.0);
AutoExecConfig(true, "greet");
}
public void OnClientPostAdminCheck(int client)
{
if (g_cvEnabled.BoolValue)
PrintToChat(client, "Welcome!");
}Most plugin settings belong in typed config. Declare them in package.json:
{
"s2script": {
"config": {
"enabled": { "type": "bool", "default": true, "description": "Greet players" }
}
}
}import { config } from '@s2script/sdk';
import type { Client } from '@s2script/sdk';
export function OnClientPostAdminCheck(client: Client): void {
if (config.getBool('enabled')) client.chat('Welcome!');
}On first load, the host writes addons/s2script/configs/<plugin-id>.json with the defaults, which works like AutoExecConfig. Operators edit that file. Subscribe with config.onChange to pick up edits without a reload.
If operators need a real console variable, register one:
import { Server } from '@s2script/sdk/server';
export function OnPluginStart(): void {
Server.registerCvar('sm_greet_enabled', {
type: 'int',
default: 1,
min: 0,
max: 1,
help: 'Greet players'
});
Server.onCvarChange('sm_greet_enabled', (_name, next) => console.log('now', next));
}
function greetEnabled(): boolean {
return Number(Server.getCvar('sm_greet_enabled')) !== 0;
}Server.getCvar returns a string. As in SourceMod, a registered cvar and its value persist across plugin reloads.
Translations#
// translations/greet.phrases.txt
"Phrases"
{
"Welcome"
{
"#format" "{1:s}"
"en" "Welcome, {1}!"
}
}public void OnPluginStart()
{
LoadTranslations("greet.phrases");
RegConsoleCmd("sm_welcome", Command_Welcome);
}
public Action Command_Welcome(int client, int args)
{
ReplyToCommand(client, "%t", "Welcome", "friend");
return Plugin_Handled;
}{
"Welcome": "{green}Welcome, {1}!"
}import { command, translations, Translations, Chat, HookResult } from '@s2script/sdk';
export function OnPluginStart(): void {
translations.load('greet');
command('sm_welcome', (cmd) => {
cmd.replyT('Welcome', 'friend');
return HookResult.Handled;
});
}
function greet(slot: number, name: string): void {
Chat.toSlot(slot, Translations.translate(slot, 'Welcome', name)); // SourceMod's %T
}Phrase files are flat JSON at translations/<set>.phrases.json. Other languages go in translations/<code>/<set>.phrases.json. {1}, {2} are positional slots, with no #format needed. Colour tags such as {green} expand on output. s2s build checks every key against the files you load, so a misspelled key is a compile error.
What’s different#
- Slots, not client indexes. A player is a 0-based slot, and the server console is
-1. SourceMod client1is slot0. Where you’d guardclient == 0for the console, checkcmd.callerSlot < 0. - Handles are connection lifetimes. A
ClientorPlayerhandle goes stale when that connection leaves. It never points at the next person in the same slot. Stale reads return defaults ("0","",null), not garbage.isValid()replacesIsClientInGamechecks on a saved handle. See client handles. - No
CloseHandle. The host tracks every command, hook, timer, database and socket your plugin owns, and tears them down on unload.OnPluginEndis for best-effort work only. - Forwards are exports. A forward you don’t export is never subscribed. Game events are the one exception: they go through
hook.on/hook.onPre, not exported functions. - Client forwards don’t replay. As in SourceMod,
OnClientPutInServeronly fires for clients who connect after load. Seed already-connected players inOnPluginStartwithClients.all(). - Async instead of callbacks. Database, HTTP and sockets return Promises.
awaitreplaces theSQL_TQuerycallback-plus-datapattern. See Async. - Hot reload keeps state if you ask. Return state from
OnPluginState, then read it back withprevious()in the new instance’sOnPluginStart. See Hot reload handoff. OnPluginStartruns once per load, not per map. Put per-map work inOnMapStart.OnMapEndis derived from the next map start, because CS2 has no level-shutdown callback.- Natives are typed contracts. A producer
publishes methods and forwards declared in a.d.ts. Consumersimportthem directly or calluse. Values cross by copy and are validated. See Interfaces. - Strict typecheck at build.
s2s buildrefuses to emit a.s2spon any type error. A failed rebuild leaves the running plugin untouched.
Next#
- Getting started: install the runtime and base plugins
- Authoring plugins: scaffold, build, plugin shape
- Plugin lifecycle: every named forward
- Commands and Events
- SDKHooks and Entities
- Inter-plugin interfaces: natives and forwards
- API overview and modules catalog