# Coming from Swiftly

This guide is for authors of **SwiftlyS2** plugins, the current C# generation of Swiftly (swiftlys2.net). The older Lua framework was archived and reached end-of-life on April 10, 2026. If you are porting a Lua plugin, the concepts below still apply, but the Swiftly snippets are C#.

Much will feel familiar. Game events still have pre and post hooks and return a `HookResult`. Commands, timers, typed config, translation files, and shared interfaces between plugins all have direct counterparts. The biggest shifts: there is no plugin class and no `Core` object. A plugin is a TypeScript npm package that exports named functions. Registration happens only while `OnPluginStart` runs. Permissions use SourceMod-style admin flags instead of permission strings.

## Concept map

| SwiftlyS2                                                                         | s2script                                                                                                                   |
| --------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
| `BasePlugin` subclass + `[PluginMetadata]`                                        | npm package (`name`, `version`) that exports `OnPluginStart` — [Authoring](https://s2script.com/docs/authoring)                                |
| `Load(bool hotReload)`                                                            | `export function OnPluginStart()`; `previous()` is `undefined` on a first load — [Lifecycle](https://s2script.com/docs/concepts/lifecycle)     |
| `Unload()`                                                                        | `export function OnPluginEnd()` (best-effort) — [Lifecycle](https://s2script.com/docs/concepts/lifecycle)                                      |
| State you rebuild after a hot reload                                              | `OnPluginState()` return value, revived as `previous()` — [Lifecycle](https://s2script.com/docs/concepts/lifecycle#hot-reload-handoff)         |
| `[Command]` / `Core.Command.RegisterCommand`                                      | `command('name', handler)` — [Commands](https://s2script.com/docs/concepts/commands)                                                           |
| `[Command(permission: "...")]` / `Core.Permission`                                | `command.admin(name, ADMFLAG.*, handler)`, `Admin.forSlot` — [admin module](https://s2script.com/docs/api/modules/admin)                       |
| `ICommandContext` (`Reply`, `Args`, `Sender`, `IsSentByPlayer`)                   | `CommandInvocation` (`reply`, `args`, `callerSlot`; `-1` = console) — [commands module](https://s2script.com/docs/api/modules/commands)        |
| `[ClientChatHookHandler]` / `HookClientChat`                                      | `export function OnClientSayCommand(slot, text, teamonly)` — [Commands](https://s2script.com/docs/concepts/commands#return-values)             |
| `[ClientCommandHookHandler]` / `HookClientCommand`                                | `command.onClientCommand(name, handler)` (per command name) — [Commands](https://s2script.com/docs/concepts/commands)                          |
| `[GameEventHandler(HookMode.Pre)]` / `HookMode.Post`                              | `hook.onPre(name, fn)` / `hook.on(name, fn)` — [Events](https://s2script.com/docs/concepts/events)                                             |
| `Core.GameEvent.Fire<T>` / `FireToPlayer<T>`                                      | `Events.fire` / `Events.fireToClient` — [events module](https://s2script.com/docs/api/modules/events)                                          |
| `HookResult.Continue` / `HookResult.Stop`                                         | `HookResult.Continue` / `Changed` / `Handled` / `Stop` — [Events](https://s2script.com/docs/concepts/events)                                   |
| `Core.Event.OnClientConnected`, `OnMapLoad`, `OnEntityCreated`, `OnTick`          | Named publics: `OnClientConnected`, `OnMapStart`, `OnEntityCreated`, `OnGameFrame` — [Lifecycle](https://s2script.com/docs/concepts/lifecycle) |
| `Core.Scheduler.DelayBySeconds` / `RepeatBySeconds`                               | `after(ms, fn)` / `every(ms, fn)`, cancel with `timer.kill()` — [Async](https://s2script.com/docs/concepts/async)                              |
| `Core.Scheduler.NextTick`                                                         | `await nextFrame()` — [timers module](https://s2script.com/docs/api/modules/timers)                                                            |
| `Core.PlayerManager.GetPlayer` / `GetAllPlayers`                                  | `Player.fromSlot` / `Player.all()` from `@s2script/cs2` — [cs2 module](https://s2script.com/docs/api/modules/cs2)                              |
| `IPlayer.PlayerPawn`                                                              | `player.pawn` or `Pawn.forSlot(slot)` — [Schema fields](https://s2script.com/docs/concepts/schema-fields)                                      |
| `IPlayer.SendChat` / `Core.PlayerManager.SendChat`                                | `Chat.toSlot` / `Chat.toAll`, or `client.chat` — [chat module](https://s2script.com/docs/api/modules/chat)                                     |
| `Core.Configuration` + `IOptionsMonitor<T>`                                       | `s2script.config` in `package.json` + `config.getString` / `config.onChange` — [Config](https://s2script.com/docs/concepts/config)             |
| `Core.Translation.GetPlayerLocalizer` / `Core.Localizer`                          | `translations.load` + `Translations.translate` / `cmd.replyT` — [Translations](https://s2script.com/docs/concepts/translations)                |
| Shared API: `AddSharedInterface` / `GetSharedInterface` / `TryGetSharedInterface` | `publish` / `use` / `tryUse` / `watchOptional` — [Interfaces](https://s2script.com/docs/concepts/interfaces)                                   |
| `Core.EntitySystem.HookEntityOutput`                                              | `onOutput(classname, output, fn)` — [Entities](https://s2script.com/docs/concepts/entities)                                                    |
| `dotnet publish` → `addons/swiftlys2/plugins/<PluginId>/`                         | `s2s build` → `.s2sp` in `addons/s2script/plugins/` — [Authoring](https://s2script.com/docs/authoring)                                         |

## Side by side

### Plugin skeleton and lifecycle

SwiftlyS2 gives you a class, a `Core` service object, and a `hotReload` flag.

```csharp
using SwiftlyS2.Shared.Plugins;
using SwiftlyS2.Shared;

namespace MyPlugin;

[PluginMetadata(Id = "MyPlugin", Version = "1.0.0", Name = "My Plugin", Author = "Author", Description = "My first plugin")]
public partial class MyPlugin : BasePlugin
{
    public MyPlugin(ISwiftlyCore core) : base(core)
    {
    }

    public override void Load(bool hotReload)
    {
        Core.Logger.LogInformation("Plugin loaded. Hot reload: {HotReload}", hotReload);
    }

    public override void Unload()
    {
    }
}
```

In s2script, the id and version come from `package.json`. Lifecycle hooks are exported functions. A hot reload hands the old instance's state to the new one.

```ts
import { previous } from '@s2script/sdk';

let loads = 0;

export function OnPluginStart(): void {
	const prev = previous() as { loads: number } | undefined; // undefined on a first load
	loads = (prev?.loads ?? 0) + 1;
	console.log('plugin loaded, hot reload:', prev !== undefined);
}

export function OnPluginState(): unknown {
	return { loads }; // serialized and revived as the next previous()
}

export function OnPluginEnd(): void {
	// best-effort cleanup; the host already tears down commands, hooks, and timers
}
```

### A command

```csharp
[Command("hello", permission: "myplugin.admin")]
public void OnHelloCommand(ICommandContext context)
{
    if (!context.IsSentByPlayer)
    {
        context.Reply("This command can only be used by players!");
        return;
    }
    context.Reply($"Hello, {context.Sender!.Name}!");
}
```

```ts
import { command, ADMFLAG, Clients, HookResult } from '@s2script/sdk';

export function OnPluginStart(): void {
	command.admin('hello', ADMFLAG.GENERIC, (cmd) => {
		if (cmd.callerSlot < 0) {
			cmd.reply('This command can only be used by players!');
			return HookResult.Handled;
		}
		cmd.reply(`Hello, ${Clients.fromSlot(cmd.callerSlot)?.name ?? 'player'}!`);
		return HookResult.Handled;
	});
}
```

Use plain `command(...)` when anyone may run it. Return `HookResult.Handled` on every path, including usage errors.

### A game event, pre and post

```csharp
[GameEventHandler(HookMode.Pre)]
public HookResult OnPlayerDeathPre(EventPlayerDeath @event)
{
    return @event.Headshot ? HookResult.Stop : HookResult.Continue;
}

[GameEventHandler(HookMode.Post)]
public HookResult OnPlayerDeath(EventPlayerDeath @event)
{
    if (@event.AttackerPlayer is { } attacker && @event.UserIdPlayer is { } victim)
        Console.WriteLine($"{attacker.Name} killed {victim.Name} with {@event.Weapon}");
    return HookResult.Continue;
}
```

```ts
import { hook, Clients, HookResult } from '@s2script/sdk';

export function OnPluginStart(): void {
	hook.onPre('player_death', (ev) => {
		if (ev.getBool('headshot')) return HookResult.Handled; // clients do not receive it
	});

	hook.on('player_death', (ev) => {
		const attacker = Clients.fromSlot(ev.getPlayerSlot('attacker'));
		const victim = Clients.fromSlot(ev.getPlayerSlot('userid'));
		if (attacker && victim) {
			console.log(`${attacker.name} killed ${victim.name} with ${ev.getString('weapon')}`);
		}
	});
}
```

Fields are read with typed getters by their raw event key. The [game events reference](https://s2script.com/docs/reference/events) lists every key. Returning nothing is the same as `HookResult.Continue`.

### Timers

```csharp
var tips = Core.Scheduler.RepeatBySeconds(30.0f, () => Core.PlayerManager.SendChat("Tip: type !help"));
Core.Scheduler.DelayBySeconds(2.0f, () => Console.WriteLine("Executed after 2 seconds."));
Core.Scheduler.NextTick(() => Console.WriteLine("Runs on next tick."));

tips.Cancel();
```

```ts
import { Chat } from '@s2script/sdk';
import { after, every, delay, nextFrame } from '@s2script/sdk/timers';

export function OnPluginStart(): void {
	const tips = every(30_000, () => Chat.toAll('Tip: type !help'));
	after(2_000, () => console.log('Executed after 2 seconds.'));

	// later: tips.kill();
}

async function countdown(): Promise<void> {
	for (let i = 3; i > 0; i--) {
		Chat.toAll(`${i}...`);
		await delay(1000);
	}
	await nextFrame();
	Chat.toAll('Go!');
}
```

Intervals are milliseconds, not ticks. `after` and `every` return a `Timer`; `kill()` cancels it. When you can `await`, prefer `delay`.

### Players and pawns

```csharp
foreach (var player in Core.PlayerManager.GetAllPlayers())
{
    if (!player.IsValid || !player.IsAlive || player.PlayerPawn == null) continue;
    player.SendChat($"Your SteamID is {player.SteamID}");
}

Core.PlayerManager.GetPlayer(0)?.Respawn();
```

```ts
import { Chat } from '@s2script/sdk';
import { Player } from '@s2script/cs2';

export function OnPluginStart(): void {
	for (const player of Player.all()) {
		const pawn = player.pawn; // null when dead or absent
		if (!pawn?.isValid) continue;
		pawn.health = 100; // generated schema field; the write notifies the engine
		Chat.toSlot(player.slot, `Your SteamID is ${player.steamId}`);
	}

	Player.fromSlot(0)?.respawn();
}
```

`steamId` is a decimal string, not a 64-bit integer. A stale `Player` or `Pawn` reads `null` instead of throwing.

### Config and translations

SwiftlyS2 binds a typed model to a JSONC file. Translations live in `resources/translations/<lang>.jsonc`.

```csharp
public class MainConfig
{
    public bool Enabled { get; set; } = true;
}

Core.Configuration
    .InitializeJsonWithModel<MainConfig>("config.jsonc", "Main")
    .Configure(builder =>
        builder.AddJsonFile(Core.Configuration.GetConfigPath("config.jsonc"), optional: false, reloadOnChange: true));

// resources/translations/en.jsonc: { "welcome.message": "[green]Welcome, {0}!" }
var localizer = Core.Translation.GetPlayerLocalizer(player);
player.SendChat(localizer["welcome.message", player.Name]);
```

In s2script, config is declared in `package.json`:

```json
{
	"s2script": {
		"config": {
			"enabled": { "type": "bool", "default": true }
		}
	}
}
```

Phrases go in `translations/myplugin.phrases.json`:

```json
{
	"Welcome": "{green}Welcome, {1}!"
}
```

```ts
import { config, translations, Translations } from '@s2script/sdk';
import type { Client } from '@s2script/sdk';

export function OnPluginStart(): void {
	translations.load('myplugin');
	config.onChange(() => console.log('enabled =', config.getBool('enabled')));
}

export function OnClientPostAdminCheck(client: Client): void {
	if (!config.getBool('enabled')) return;
	client.chat(Translations.translate(client.slot, 'Welcome', client.name));
}
```

The operator's override file is generated at `addons/s2script/configs/<plugin-id>.json`. A translator adds `translations/<code>/myplugin.phrases.json`.

## What's different

- **No `Core`, no DI.** Capabilities are plain imports. Engine-generic APIs come from `@s2script/sdk`. `Player`, `Pawn`, and `ChatColors` come from `@s2script/cs2`.
- **Registration has a window.** `command`, `hook.on` / `hook.onPre`, `translations.load`, `onOutput`, and `publish` / `use` throw once `OnPluginStart` returns. Do not register at module top level either; that runs before the window opens.

> [!IMPORTANT]
> There is no `Load(bool hotReload)` flag. Check `previous()` in `OnPluginStart`: it is `undefined` on a first load and holds your `OnPluginState` return after a hot reload.

- **Core events are exported functions.** Instead of `Core.Event.OnClientConnected += ...`, export `OnClientConnected(client)`. A missing export is simply not subscribed. Client publics fire only for clients that connect after your plugin loads; seed existing ones with `Clients.all()` in `OnPluginStart`.
- **Pre-hooks hide, they do not cancel.** `HookResult.Handled` and `Stop` keep the event from reaching clients. The server still processes it. To block damage, use `OnTakeDamage` or [SDKHooks](https://s2script.com/docs/concepts/sdkhooks). `Changed` broadcasts an event you edited with `setInt` / `setString` / ….

> [!WARNING]
> A `GameEvent` is valid only while your handler runs synchronously. Read fields before any `await`.

- **`nextTick` is not Swiftly's `NextTick`.** In s2script, `nextTick()` yields to the next microtick. The equivalent of `Core.Scheduler.NextTick` is `await nextFrame()`.
- **Timers are milliseconds.** There are no tick-count timers and no `StopOnMapChange`. Plugins persist across map changes; kill a timer yourself in `OnMapEnd` if it should not survive one.
- **Permissions are flags, not strings.** Swiftly checks keys like `myplugin.admin.kick` with wildcards and sub-permissions. s2script uses the SourceMod model: an `ADMFLAG` bitmask per admin, loaded from `admins.json`. Check a player with `Admin.forSlot(slot)?.hasFlags(ADMFLAG.KICK)`.
- **No `sw_` prefix.** Swiftly registers `[Command("heal")]` as `sw_heal` unless `registerRaw` is set. s2script registers the name you pass. Chat `!` / `/` triggers reach the same registry.
- **Translation syntax differs.** Placeholders are 1-based (`{1}`), not 0-based (`{0}`). Colour tags use braces (`{green}`), not brackets (`[green]`). Keys you use are checked at build time against the phrase files you load.
- **Handles follow a connection.** A `Client` or `Player` handle is tied to one connection lifetime, not a slot. After a reconnect, look the player up again. Identify players across time by `userId` or `steamId`.
- **Interfaces are declared in `package.json`.** Instead of a shared contracts assembly, the producer lists what it `publishes` and consumers list `pluginDependencies` or `optionalPluginDependencies`. Use `watchOptional` to follow an optional provider across reloads.
- **Teardown is automatic.** The host tracks every command, hook, timer, socket, and interface your plugin owns and releases them on unload. `OnPluginEnd` is for best-effort work only.

> [!NOTE]
> s2script runs on CS2 Linux servers (`linuxsteamrt64`) under stock Metamod:Source. See [Getting started](https://s2script.com/docs/getting-started).

## Next

- [Getting started](https://s2script.com/docs/getting-started): install the runtime
- [Authoring plugins](https://s2script.com/docs/authoring): scaffold with `npx @s2script/sdk create`, then `s2s build`
- [Plugin lifecycle](https://s2script.com/docs/concepts/lifecycle): every named public
- [Commands](https://s2script.com/docs/concepts/commands) · [Events](https://s2script.com/docs/concepts/events) · [Async & timers](https://s2script.com/docs/concepts/async)
- [Config](https://s2script.com/docs/concepts/config) · [Translations](https://s2script.com/docs/concepts/translations) · [Interfaces](https://s2script.com/docs/concepts/interfaces)
- [Modules catalog](https://s2script.com/docs/api/modules)
