# 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](https://s2script.com/docs/concepts/lifecycle)                                      |
| `ModuleName` / `ModuleVersion`                                         | `name` / `version` in [`package.json`](https://s2script.com/docs/authoring)                                                                        |
| `Load(bool hotReload)`                                                 | [`OnPluginStart`](https://s2script.com/docs/concepts/lifecycle)                                                                                    |
| `Unload(bool hotReload)`                                               | [`OnPluginEnd`](https://s2script.com/docs/concepts/lifecycle) (best-effort; the host tears down registrations for you)                             |
| The `hotReload` flag                                                   | [`previous()`](https://s2script.com/docs/concepts/lifecycle) returns what the old instance's `OnPluginState` returned                              |
| `OnAllPluginsLoaded`                                                   | [`OnAllPluginsLoaded`](https://s2script.com/docs/concepts/lifecycle)                                                                               |
| `[ConsoleCommand]` / `AddCommand`                                      | [`command(name, handler)`](https://s2script.com/docs/concepts/commands) inside `OnPluginStart`                                                     |
| `CommandInfo` (`GetArg`, `ArgCount`, `ReplyToCommand`)                 | [`Command`](https://s2script.com/docs/api/modules/commands) (`arg(n)`, `argCount`, `reply`)                                                        |
| `[RequiresPermissions("@css/slay")]`                                   | [`command.admin(name, ADMFLAG.SLAY, handler)`](https://s2script.com/docs/api/modules/admin)                                                        |
| `CommandUsage.SERVER_ONLY`                                             | [`command.server`](https://s2script.com/docs/concepts/commands)                                                                                    |
| `AddCommandListener`                                                   | [`command.onClientCommand`](https://s2script.com/docs/concepts/commands)                                                                           |
| `[GameEventHandler]` / `RegisterEventHandler<T>`                       | [`hook.on`](https://s2script.com/docs/concepts/events), or typed `Events.on` from `@s2script/cs2`                                                  |
| `HookMode.Pre` and `info.DontBroadcast`                                | [`hook.onPre`](https://s2script.com/docs/concepts/events) returning `HookResult.Handled`                                                           |
| `RegisterListener<Listeners.OnMapStart>`, `OnClientPutInServer`, …     | Exported publics: `OnMapStart`, `OnClientPutInServer`, `OnGameFrame`, … ([lifecycle](https://s2script.com/docs/concepts/lifecycle))                |
| `AddTimer` / `TimerFlags.REPEAT`                                       | [`after` / `every`](https://s2script.com/docs/api/modules/timers), or `await delay(ms)`                                                            |
| `Server.NextFrame`                                                     | [`nextFrame()`](https://s2script.com/docs/concepts/async)                                                                                          |
| `Utilities.GetPlayers()` / `GetPlayerFromSlot` / `GetPlayerFromUserid` | [`Player.all()` / `Player.fromSlot` / `Player.fromUserId`](https://s2script.com/docs/api/modules/cs2)                                              |
| `player.PlayerPawn.Value`                                              | [`player.pawn`](https://s2script.com/docs/concepts/entities) or `Pawn.forSlot(slot)`                                                               |
| Schema properties + `Utilities.SetStateChanged`                        | Generated [schema accessors](https://s2script.com/docs/concepts/schema-fields); writes notify the engine for you                                   |
| `Server.PrintToChatAll` / `player.PrintToChat`                         | [`Chat.toAll` / `Chat.toSlot`](https://s2script.com/docs/api/modules/chat)                                                                         |
| `Server.ExecuteCommand`                                                | [`Server.command`](https://s2script.com/docs/api/modules/server)                                                                                   |
| `IPluginConfig<T>` / `BasePluginConfig`                                | [`s2script.config`](https://s2script.com/docs/concepts/config) in `package.json`, read with `config.getString` and friends                         |
| `Localizer[...]`                                                       | [`translations.load`](https://s2script.com/docs/concepts/translations) + `cmd.replyT` / `Translations.translate`                                   |
| `PluginCapability<T>` / `Capabilities.RegisterPluginCapability`        | [`publish` / `use` / `watchOptional`](https://s2script.com/docs/concepts/interfaces)                                                               |
| `VirtualFunctions` / `MemoryFunctionVoid` `.Hook(...)`                 | [`Engine.function`](https://s2script.com/docs/concepts/engine-calls) with `.onPre` / `.onPost`, or [`SDKHook`](https://s2script.com/docs/concepts/sdkhooks) for damage |
| `.dll` + `.deps.json` in `plugins/<Name>/`                             | One `.s2sp` in `addons/s2script/plugins/` ([publishing](https://s2script.com/docs/publishing))                                                     |

## Side by side

### Plugin skeleton and lifecycle

```csharp
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) { }
}
```

```ts
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

```csharp
[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.");
}
```

```ts
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

```csharp
[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;
}
```

```ts
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](https://s2script.com/docs/reference/events) lists every field.

### Timers

```csharp
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;
}
```

```ts
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

```csharp
[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");
}
```

```ts
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](https://s2script.com/docs/concepts/entities).

### Config

```csharp
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;
    }
}
```

```json
{
	"s2script": {
		"config": {
			"chat_prefix": {
				"type": "string",
				"default": "My Cool Plugin",
				"description": "Chat prefix"
			},
			"chat_interval": { "type": "float", "default": 60 }
		}
	}
}
```

```ts
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](https://s2script.com/docs/concepts/lifecycle#the-load-window).

> [!WARNING]
> Registration APIs throw outside the load window. Code at the top level of the module runs before the window opens, and callbacks run after it closes. Put every `command`, `hook.on` and `publish` call inside `OnPluginStart`.

- **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`.

> [!WARNING]
> `HookResult` names match, but the numbers do not. In CounterStrikeSharp `Handled` is `3` and `Stop` is `4`. In s2script `Handled` is `2` and `Stop` is `3`. Always use the names.

- **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](https://s2script.com/docs/concepts/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](https://s2script.com/docs/concepts/async).
- **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.

> [!NOTE]
> Inter-plugin APIs are typed contracts, not shared DLLs. A producer declares its contract in a `.d.ts` file and calls `publish`. Consumers `use` it or import its methods directly. There is no `shared/` folder. See [Interfaces](https://s2script.com/docs/concepts/interfaces).

## 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](https://s2script.com/docs/concepts/schema-fields#what-is-not-exposed).
- **Some SDKHooks.** `OnTakeDamageAlive`, `TraceAttack`, `FireBulletsPost` and `ReloadPost` have no live path yet. See [SDKHooks](https://s2script.com/docs/concepts/sdkhooks).
- **Dropping a weapon.** `Pawn.dropActiveWeapon` currently always returns `false`.

## Next

- [Getting started](https://s2script.com/docs/getting-started): install the runtime next to Metamod
- [Authoring plugins](https://s2script.com/docs/authoring): scaffold with `npx @s2script/sdk create`
- [Plugin lifecycle](https://s2script.com/docs/concepts/lifecycle): every named public, hot reload, map changes
- [Commands](https://s2script.com/docs/concepts/commands) and [Events](https://s2script.com/docs/concepts/events)
- [Schema fields](https://s2script.com/docs/concepts/schema-fields) and [Entities](https://s2script.com/docs/concepts/entities)
- [Inter-plugin interfaces](https://s2script.com/docs/concepts/interfaces)
- [Publishing](https://s2script.com/docs/publishing): ship to the registry with `s2s deploy`
