Coming from CounterStrikeSharp
Most of what you know from CounterStrikeSharp carries over: Metamod:Source, game events with pre and post hooks, HookResult, controllers and pawns, schema fields, and admin-gated commands. The main change is the shape of a plugin. A CounterStrikeSharp plugin is a C# class that derives from BasePlugin and uses attributes. An s2script plugin is a TypeScript module that exports named functions like OnPluginStart, and it registers everything by calling functions. The code runs in an embedded JavaScript runtime, not .NET. You build it with s2s build into one .s2sp file, and the server hot-reloads that file when you replace it.
Concept map#
| CounterStrikeSharp | s2script |
|---|---|
class MyPlugin : BasePlugin | A module that exports OnPluginStart and other named publics |
ModuleName / ModuleVersion | name / version in package.json |
Load(bool hotReload) | OnPluginStart |
Unload(bool hotReload) | OnPluginEnd (best-effort; the host tears down registrations for you) |
The hotReload flag | previous() returns what the old instance’s OnPluginState returned |
OnAllPluginsLoaded | OnAllPluginsLoaded |
[ConsoleCommand] / AddCommand | command(name, handler) inside OnPluginStart |
CommandInfo (GetArg, ArgCount, ReplyToCommand) | Command (arg(n), argCount, reply) |
[RequiresPermissions("@css/slay")] | command.admin(name, ADMFLAG.SLAY, handler) |
CommandUsage.SERVER_ONLY | command.server |
AddCommandListener | command.onClientCommand |
[GameEventHandler] / RegisterEventHandler<T> | hook.on, or typed Events.on from @s2script/cs2 |
HookMode.Pre and info.DontBroadcast | hook.onPre returning HookResult.Handled |
RegisterListener<Listeners.OnMapStart>, OnClientPutInServer, … | Exported publics: OnMapStart, OnClientPutInServer, OnGameFrame, … (lifecycle) |
AddTimer / TimerFlags.REPEAT | after / every, or await delay(ms) |
Server.NextFrame | nextFrame() |
Utilities.GetPlayers() / GetPlayerFromSlot / GetPlayerFromUserid | Player.all() / Player.fromSlot / Player.fromUserId |
player.PlayerPawn.Value | player.pawn or Pawn.forSlot(slot) |
Schema properties + Utilities.SetStateChanged | Generated schema accessors; writes notify the engine for you |
Server.PrintToChatAll / player.PrintToChat | Chat.toAll / Chat.toSlot |
Server.ExecuteCommand | Server.command |
IPluginConfig<T> / BasePluginConfig | s2script.config in package.json, read with config.getString and friends |
Localizer[...] | translations.load + cmd.replyT / Translations.translate |
PluginCapability<T> / Capabilities.RegisterPluginCapability | publish / use / watchOptional |
VirtualFunctions / MemoryFunctionVoid .Hook(...) | Engine.function with .onPre / .onPost, or SDKHook for damage |
.dll + .deps.json in plugins/<Name>/ | One .s2sp in addons/s2script/plugins/ (publishing) |
Side by side#
Plugin skeleton and lifecycle#
public class GreeterPlugin : BasePlugin
{
public override string ModuleName => "Greeter";
public override string ModuleVersion => "1.0.0";
private int _greeted;
public override void Load(bool hotReload)
{
RegisterListener<Listeners.OnMapStart>(mapName =>
Logger.LogInformation("Map {Map}", mapName));
RegisterListener<Listeners.OnClientAuthorized>((slot, steamId) =>
{
_greeted++;
Utilities.GetPlayerFromSlot(slot)?.PrintToChat("Welcome!");
});
}
public override void Unload(bool hotReload) { }
}import { previous } from '@s2script/sdk';
import type { Client } from '@s2script/sdk';
let greeted = 0;
export function OnPluginStart(): void {
const prev = previous() as { greeted: number } | undefined;
greeted = prev?.greeted ?? 0; // survives a hot reload
}
export function OnMapStart(mapName: string): void {
console.log('map', mapName);
}
export function OnClientPostAdminCheck(client: Client): void {
greeted++;
client.chat('Welcome!');
}
export function OnPluginState(): unknown {
return { greeted }; // handed to the next instance as previous()
}
export function OnPluginEnd(): void {
// optional; commands, hooks and timers are removed for you
}Name and version live in package.json. Listeners become exported functions: export one and the host subscribes it. Leave it out and nothing is subscribed.
A command with a permission#
[ConsoleCommand("css_slay", "Slay a player by userid")]
[CommandHelper(minArgs: 1, usage: "<userid>", whoCanExecute: CommandUsage.CLIENT_AND_SERVER)]
[RequiresPermissions("@css/slay")]
public void OnSlay(CCSPlayerController? caller, CommandInfo info)
{
var target = Utilities.GetPlayerFromUserid(int.Parse(info.GetArg(1)));
target?.PlayerPawn.Value?.CommitSuicide(false, true);
info.ReplyToCommand("Slayed.");
}import { command, ADMFLAG, HookResult } from '@s2script/sdk';
import { Player } from '@s2script/cs2';
export function OnPluginStart(): void {
command.admin('sm_slay', ADMFLAG.SLAY, (cmd) => {
if (cmd.argCount < 1) {
cmd.reply('Usage: sm_slay <target>');
return HookResult.Handled;
}
const targets = Player.target(cmd.arg(0), cmd.callerSlot); // #userid, name, @all, @me
for (const target of targets) target.pawn?.slay();
cmd.reply(`Slayed ${targets.length} player(s).`);
return HookResult.Handled;
});
}Arguments are 0-based and exclude the command name: cmd.arg(0) is the first argument, where CounterStrikeSharp uses GetArg(1). Chat triggers work for any command: !slay and /slay try slay first, then sm_slay. The server console calls with callerSlot set to -1.
An event hook, pre and post#
[GameEventHandler(HookMode.Pre)]
public HookResult OnPlayerDeathPre(EventPlayerDeath @event, GameEventInfo info)
{
if (!@event.Headshot)
{
info.DontBroadcast = true;
}
return HookResult.Continue;
}
[GameEventHandler]
public HookResult OnRoundStart(EventRoundStart @event, GameEventInfo info)
{
Logger.LogInformation("Round start, timelimit {Timelimit}", @event.Timelimit);
return HookResult.Continue;
}import { hook, HookResult } from '@s2script/sdk';
export function OnPluginStart(): void {
hook.onPre('player_death', (ev) => {
if (!ev.getBool('headshot')) {
return HookResult.Handled; // clients do not get the event; the server still processes it
}
return HookResult.Continue;
});
hook.on('round_start', (ev) => {
console.log('round start, timelimit', ev.getInt('timelimit'));
});
}Events use their engine names (player_death), not C# classes (EventPlayerDeath). Fields are read by key: ev.getPlayerSlot('userid') gives a slot, and Player.fromSlot turns it into a player. For compile-time checks on event names and keys, use Events.on from @s2script/cs2. The game events reference lists every field.
Timers#
public override void Load(bool hotReload)
{
AddTimer(60.0f, () => Server.PrintToChatAll("Type !help for commands"), TimerFlags.REPEAT);
AddTimer(3.0f, () => Server.PrintToChatAll("3 seconds after load"));
}
[GameEventHandler]
public HookResult OnRoundStart(EventRoundStart @event, GameEventInfo info)
{
AddTimer(5.0f, () => Server.PrintToChatAll("5 seconds into the round"));
return HookResult.Continue;
}import { hook, Chat } from '@s2script/sdk';
import { after, every, delay } from '@s2script/sdk/timers';
import type { Timer } from '@s2script/sdk/timers';
let tips: Timer | undefined;
async function announceLater(): Promise<void> {
await delay(5_000);
Chat.toAll('5 seconds into the round');
}
export function OnPluginStart(): void {
tips = every(60_000, () => Chat.toAll('Type !help for commands'));
after(3_000, () => Chat.toAll('3 seconds after load'));
hook.on('round_start', () => {
void announceLater();
});
}Intervals are milliseconds, not seconds. every and after return a Timer with kill() and alive. When you can await, delay is simpler. All timers die when the plugin unloads.
Reading and writing a pawn field#
[ConsoleCommand("css_heal", "Set your health to 100")]
public void OnHeal(CCSPlayerController? player, CommandInfo info)
{
var pawn = player?.PlayerPawn.Value;
if (pawn == null || !pawn.IsValid) return;
pawn.Health = 100;
Utilities.SetStateChanged(pawn, "CBaseEntity", "m_iHealth");
}import { command, HookResult } from '@s2script/sdk';
import { Pawn } from '@s2script/cs2';
export function OnPluginStart(): void {
command('heal', (cmd) => {
const pawn = Pawn.forSlot(cmd.callerSlot);
if (!pawn?.isValid) {
cmd.reply('You need to be alive.');
return HookResult.Handled;
}
pawn.health = 100; // m_iHealth; the network update is sent for you
return HookResult.Handled;
});
}Field names are camelCase and have no Hungarian prefix (m_iHealth becomes health). A read on a stale entity returns null instead of crashing. Some fields do nothing when written, for example gravity scale. Use the engine-call methods (setGravityScale, applyAbsVelocityImpulse) for those. See Entities.
Config#
public class SampleConfig : BasePluginConfig
{
[JsonPropertyName("ChatPrefix")] public string ChatPrefix { get; set; } = "My Cool Plugin";
[JsonPropertyName("ChatInterval")] public float ChatInterval { get; set; } = 60;
}
public class WithConfigPlugin : BasePlugin, IPluginConfig<SampleConfig>
{
public override string ModuleName => "Example: With Config";
public override string ModuleVersion => "1.0.0";
public SampleConfig Config { get; set; }
public void OnConfigParsed(SampleConfig config)
{
if (config.ChatInterval > 60) config.ChatInterval = 60;
Config = config;
}
}{
"s2script": {
"config": {
"chat_prefix": {
"type": "string",
"default": "My Cool Plugin",
"description": "Chat prefix"
},
"chat_interval": { "type": "float", "default": 60 }
}
}
}import { config } from '@s2script/sdk';
function chatInterval(): number {
return Math.min(config.getFloat('chat_interval'), 60); // validate on read
}
export function OnPluginStart(): void {
console.log(config.getString('chat_prefix'), chatInterval());
config.onChange(() => {
console.log('config reloaded, interval =', chatInterval());
});
}The schema lives in package.json, not in a class. The host writes addons/s2script/configs/<plugin-id>.json with defaults on first load. Admins edit that file. Subscribing with config.onChange makes edits apply without a plugin reload.
What’s different#
- No attributes, no reflection. Nothing is discovered from method attributes. Commands, event hooks, interfaces and translations are registered by calling functions inside
OnPluginStart. This is the load window.
- Hot reload keeps state if you ask. Replace the
.s2spand the plugin reloads. WhateverOnPluginStatereturns is serialized as JSON and handed to the new instance throughprevious(). Store 64-bit values such as SteamIDs as strings. Abigintcannot be serialized and drops the whole handoff. - The host owns cleanup. Commands, hooks, timers, sockets and database handles are tracked per plugin and released on unload. You do not need to deregister anything in
OnPluginEnd.
- Commands return a result. Return
HookResult.Handledfrom every path of a command you own, including usage errors. Returning nothing meansContinue. - Pre-event results only affect the broadcast. In
hook.onPre,HandledandStopstop clients from receiving the event. The server still processes it. This matchesinfo.DontBroadcast, not blocking the underlying game logic. To block damage, useOnTakeDamageor SDKHooks. - Admin flags are bitmasks. Permissions are SourceMod flags (
ADMFLAG.KICK,ADMFLAG.SLAY, …), not@css/...strings. For plugin-specific rights, useADMFLAG.CUSTOM1toADMFLAG.CUSTOM6. Check a player withAdmin.forSlot(slot)?.hasFlags(...). - Players are handles for one connection. A saved
PlayerorClientnever points at a new player who reuses the slot. When a handle goes stale, reads returnnullor defaults. Checks likeIsValidmostly become null checks. For pawn writes, checkpawn.isValid. - Plugins survive map changes.
OnPluginStartruns once, not per map. There is noSTOP_ON_MAPCHANGE: kill map-scoped timers yourself inOnMapEnd. - Client publics only see new events.
OnClientPostAdminCheckand similar fire for clients who connect after your plugin loads. To handle players already on the server, loop overClients.all()inOnPluginStart. - No raw pointers. Entities are
EntityRefvalues that the host checks on every access. For unwrapped engine functions, declare them ingamedata/functions.jsoncand bind them withEngine.functionfrom@s2script/sdk/unsafe. Do not copy CounterStrikeSharp signatures or vtable offsets without checking them against yourlibserver.so. - Async is Promises.
fetch, WebSocket, TCP/UDP andDatabasereturn Promises that resolve on a game frame. Nothing blocks the main thread. See Async & networking. - The build is the type check.
s2s buildtype-checks strictly and refuses to produce a.s2spon any error. A failed reload leaves the running plugin untouched.
Not supported yet#
- Linux only. The runtime targets CS2 on
linuxsteamrt64. There is no Windows build. - Config values are scalars.
s2script.configsupportsstring,int,floatandbool. A nested C# config class needs to be flattened into keys, or stored as a raw file withconfig.readFile/config.writeFile. - Named custom permissions. There is no equivalent of
@custom/permissionstrings orRequiresPermissionsOr. Use the sixCUSTOMflags. - Some schema fields. Raw pointers,
CUtlVector,CUtlStringand a few quantized vector types are not exposed. Enum fields are plain numbers because enumerator names are not generated yet. See Schema fields. - Some SDKHooks.
OnTakeDamageAlive,TraceAttack,FireBulletsPostandReloadPosthave no live path yet. See SDKHooks. - Dropping a weapon.
Pawn.dropActiveWeaponcurrently always returnsfalse.
Next#
- Getting started: install the runtime next to Metamod
- Authoring plugins: scaffold with
npx @s2script/sdk create - Plugin lifecycle: every named public, hot reload, map changes
- Commands and Events
- Schema fields and Entities
- Inter-plugin interfaces
- Publishing: ship to the registry with
s2s deploy