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 |
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 reload | OnPluginState() return value, revived as previous() — Lifecycle |
[Command] / Core.Command.RegisterCommand | command('name', handler) — Commands |
[Command(permission: "...")] / Core.Permission | command.admin(name, ADMFLAG.*, handler), Admin.forSlot — admin module |
ICommandContext (Reply, Args, Sender, IsSentByPlayer) | CommandInvocation (reply, args, callerSlot; -1 = console) — commands module |
[ClientChatHookHandler] / HookClientChat | export function OnClientSayCommand(slot, text, teamonly) — Commands |
[ClientCommandHookHandler] / HookClientCommand | command.onClientCommand(name, handler) (per command name) — Commands |
[GameEventHandler(HookMode.Pre)] / HookMode.Post | hook.onPre(name, fn) / hook.on(name, fn) — Events |
Core.GameEvent.Fire<T> / FireToPlayer<T> | Events.fire / Events.fireToClient — events module |
HookResult.Continue / HookResult.Stop | HookResult.Continue / Changed / Handled / Stop — Events |
Core.Event.OnClientConnected, OnMapLoad, OnEntityCreated, OnTick | Named publics: OnClientConnected, OnMapStart, OnEntityCreated, OnGameFrame — Lifecycle |
Core.Scheduler.DelayBySeconds / RepeatBySeconds | after(ms, fn) / every(ms, fn), cancel with timer.kill() — Async |
Core.Scheduler.NextTick | await nextFrame() — timers module |
Core.PlayerManager.GetPlayer / GetAllPlayers | Player.fromSlot / Player.all() from @s2script/cs2 — cs2 module |
IPlayer.PlayerPawn | player.pawn or Pawn.forSlot(slot) — Schema fields |
IPlayer.SendChat / Core.PlayerManager.SendChat | Chat.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.Localizer | translations.load + Translations.translate / cmd.replyT — Translations |
Shared API: AddSharedInterface / GetSharedInterface / TryGetSharedInterface | publish / use / tryUse / watchOptional — Interfaces |
Core.EntitySystem.HookEntityOutput | onOutput(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, andChatColorscome from@s2script/cs2. - Registration has a window.
command,hook.on/hook.onPre,translations.load,onOutput, andpublish/usethrow onceOnPluginStartreturns. Do not register at module top level either; that runs before the window opens.
- Core events are exported functions. Instead of
Core.Event.OnClientConnected += ..., exportOnClientConnected(client). A missing export is simply not subscribed. Client publics fire only for clients that connect after your plugin loads; seed existing ones withClients.all()inOnPluginStart. - Pre-hooks hide, they do not cancel.
HookResult.HandledandStopkeep the event from reaching clients. The server still processes it. To block damage, useOnTakeDamageor SDKHooks.Changedbroadcasts an event you edited withsetInt/setString/ ….
nextTickis not Swiftly’sNextTick. In s2script,nextTick()yields to the next microtick. The equivalent ofCore.Scheduler.NextTickisawait nextFrame().- Timers are milliseconds. There are no tick-count timers and no
StopOnMapChange. Plugins persist across map changes; kill a timer yourself inOnMapEndif it should not survive one. - Permissions are flags, not strings. Swiftly checks keys like
myplugin.admin.kickwith wildcards and sub-permissions. s2script uses the SourceMod model: anADMFLAGbitmask per admin, loaded fromadmins.json. Check a player withAdmin.forSlot(slot)?.hasFlags(ADMFLAG.KICK). - No
sw_prefix. Swiftly registers[Command("heal")]assw_healunlessregisterRawis 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
ClientorPlayerhandle is tied to one connection lifetime, not a slot. After a reconnect, look the player up again. Identify players across time byuserIdorsteamId. - Interfaces are declared in
package.json. Instead of a shared contracts assembly, the producer lists what itpublishesand consumers listpluginDependenciesoroptionalPluginDependencies. UsewatchOptionalto 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.
OnPluginEndis for best-effort work only.
Next#
- Getting started: install the runtime
- Authoring plugins: scaffold with
npx @s2script/sdk create, thens2s build - Plugin lifecycle: every named public
- Commands · Events · Async & timers
- Config · Translations · Interfaces
- Modules catalog