Search docs
Search guides, modules and symbols
From Swiftly

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#

SwiftlyS2s2script
BasePlugin subclass + [PluginMetadata]npm package (name, version) that exports OnPluginStart — Authoring
Load(bool hotReload)export function OnPluginStart(); previous() is undefined on a first load — Lifecycle
Unload()export function OnPluginEnd() (best-effort) — Lifecycle
State you rebuild after a hot reloadOnPluginState() return value, revived as previous() — Lifecycle
[Command] / Core.Command.RegisterCommandcommand('name', handler) — Commands
[Command(permission: "...")] / Core.Permissioncommand.admin(name, ADMFLAG.*, handler), Admin.forSlot — admin module
ICommandContext (Reply, Args, Sender, IsSentByPlayer)CommandInvocation (reply, args, callerSlot; -1 = console) — commands module
[ClientChatHookHandler] / HookClientChatexport function OnClientSayCommand(slot, text, teamonly) — Commands
[ClientCommandHookHandler] / HookClientCommandcommand.onClientCommand(name, handler) (per command name) — Commands
[GameEventHandler(HookMode.Pre)] / HookMode.Posthook.onPre(name, fn) / hook.on(name, fn) — Events
Core.GameEvent.Fire<T> / FireToPlayer<T>Events.fire / Events.fireToClient — events module
HookResult.Continue / HookResult.StopHookResult.Continue / Changed / Handled / Stop — Events
Core.Event.OnClientConnected, OnMapLoad, OnEntityCreated, OnTickNamed publics: OnClientConnected, OnMapStart, OnEntityCreated, OnGameFrame — Lifecycle
Core.Scheduler.DelayBySeconds / RepeatBySecondsafter(ms, fn) / every(ms, fn), cancel with timer.kill() — Async
Core.Scheduler.NextTickawait nextFrame() — timers module
Core.PlayerManager.GetPlayer / GetAllPlayersPlayer.fromSlot / Player.all() from @s2script/cs2 — cs2 module
IPlayer.PlayerPawnplayer.pawn or Pawn.forSlot(slot) — Schema fields
IPlayer.SendChat / Core.PlayerManager.SendChatChat.toSlot / Chat.toAll, or client.chat — chat module
Core.Configuration + IOptionsMonitor<T>s2script.config in package.json + config.getString / config.onChange — Config
Core.Translation.GetPlayerLocalizer / Core.Localizertranslations.load + Translations.translate / cmd.replyT — Translations
Shared API: AddSharedInterface / GetSharedInterface / TryGetSharedInterfacepublish / use / tryUse / watchOptional — Interfaces
Core.EntitySystem.HookEntityOutputonOutput(classname, output, fn) — Entities
dotnet publish → addons/swiftlys2/plugins/<PluginId>/s2s build → .s2sp in addons/s2script/plugins/ — Authoring

Side by side#

Plugin skeleton and lifecycle#

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

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.

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#

[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}!");
}
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#

[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;
}
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 lists every key. Returning nothing is the same as HookResult.Continue.

Timers#

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();
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#

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();
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.

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:

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

Phrases go in translations/myplugin.phrases.json:

{
	"Welcome": "{green}Welcome, {1}!"
}
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.
  • 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. Changed broadcasts an event you edited with setInt / setString / ….
  • 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.

Next#