Search docs
Search guides, modules and symbols
From CounterStrikeSharp

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#

CounterStrikeSharps2script
class MyPlugin : BasePluginA module that exports OnPluginStart and other named publics
ModuleName / ModuleVersionname / version in package.json
Load(bool hotReload)OnPluginStart
Unload(bool hotReload)OnPluginEnd (best-effort; the host tears down registrations for you)
The hotReload flagprevious() returns what the old instance’s OnPluginState returned
OnAllPluginsLoadedOnAllPluginsLoaded
[ConsoleCommand] / AddCommandcommand(name, handler) inside OnPluginStart
CommandInfo (GetArg, ArgCount, ReplyToCommand)Command (arg(n), argCount, reply)
[RequiresPermissions("@css/slay")]command.admin(name, ADMFLAG.SLAY, handler)
CommandUsage.SERVER_ONLYcommand.server
AddCommandListenercommand.onClientCommand
[GameEventHandler] / RegisterEventHandler<T>hook.on, or typed Events.on from @s2script/cs2
HookMode.Pre and info.DontBroadcasthook.onPre returning HookResult.Handled
RegisterListener<Listeners.OnMapStart>, OnClientPutInServer, …Exported publics: OnMapStart, OnClientPutInServer, OnGameFrame, … (lifecycle)
AddTimer / TimerFlags.REPEATafter / every, or await delay(ms)
Server.NextFramenextFrame()
Utilities.GetPlayers() / GetPlayerFromSlot / GetPlayerFromUseridPlayer.all() / Player.fromSlot / Player.fromUserId
player.PlayerPawn.Valueplayer.pawn or Pawn.forSlot(slot)
Schema properties + Utilities.SetStateChangedGenerated schema accessors; writes notify the engine for you
Server.PrintToChatAll / player.PrintToChatChat.toAll / Chat.toSlot
Server.ExecuteCommandServer.command
IPluginConfig<T> / BasePluginConfigs2script.config in package.json, read with config.getString and friends
Localizer[...]translations.load + cmd.replyT / Translations.translate
PluginCapability<T> / Capabilities.RegisterPluginCapabilitypublish / 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 .s2sp and the plugin reloads. Whatever OnPluginState returns is serialized as JSON and handed to the new instance through previous(). Store 64-bit values such as SteamIDs as strings. A bigint cannot 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.Handled from every path of a command you own, including usage errors. Returning nothing means Continue.
  • Pre-event results only affect the broadcast. In hook.onPre, Handled and Stop stop clients from receiving the event. The server still processes it. This matches info.DontBroadcast, not blocking the underlying game logic. To block damage, use OnTakeDamage or SDKHooks.
  • Admin flags are bitmasks. Permissions are SourceMod flags (ADMFLAG.KICK, ADMFLAG.SLAY, …), not @css/... strings. For plugin-specific rights, use ADMFLAG.CUSTOM1 to ADMFLAG.CUSTOM6. Check a player with Admin.forSlot(slot)?.hasFlags(...).
  • Players are handles for one connection. A saved Player or Client never points at a new player who reuses the slot. When a handle goes stale, reads return null or defaults. Checks like IsValid mostly become null checks. For pawn writes, check pawn.isValid.
  • Plugins survive map changes. OnPluginStart runs once, not per map. There is no STOP_ON_MAPCHANGE: kill map-scoped timers yourself in OnMapEnd.
  • Client publics only see new events. OnClientPostAdminCheck and similar fire for clients who connect after your plugin loads. To handle players already on the server, loop over Clients.all() in OnPluginStart.
  • No raw pointers. Entities are EntityRef values that the host checks on every access. For unwrapped engine functions, declare them in gamedata/functions.jsonc and bind them with Engine.function from @s2script/sdk/unsafe. Do not copy CounterStrikeSharp signatures or vtable offsets without checking them against your libserver.so.
  • Async is Promises. fetch, WebSocket, TCP/UDP and Database return Promises that resolve on a game frame. Nothing blocks the main thread. See Async & networking.
  • The build is the type check. s2s build type-checks strictly and refuses to produce a .s2sp on 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.config supports string, int, float and bool. A nested C# config class needs to be flattened into keys, or stored as a raw file with config.readFile / config.writeFile.
  • Named custom permissions. There is no equivalent of @custom/permission strings or RequiresPermissionsOr. Use the six CUSTOM flags.
  • Some schema fields. Raw pointers, CUtlVector, CUtlString and 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, FireBulletsPost and ReloadPost have no live path yet. See SDKHooks.
  • Dropping a weapon. Pawn.dropActiveWeapon currently always returns false.

Next#