Coming from ModSharp
This page is for ModSharp module authors moving to s2script. Much will feel familiar: named game events with pre and post handlers, client lifecycle callbacks, commands that return a result, repeating timers, and typed APIs shared between modules. The big shifts are the language (TypeScript, not C#), the plugin shape (exported functions, not a class that implements IModSharpModule), and cleanup (the host tears down everything you registered, so there is no Shutdown bookkeeping).
Concept map#
| ModSharp | s2script |
|---|---|
Class implementing IModSharpModule | A package that exports OnPluginStart — Authoring |
Init() / PostInit() | OnPluginStart — Lifecycle |
Shutdown() | OnPluginEnd (best-effort; the host ledger does the real teardown) — Lifecycle |
OnAllModulesLoaded() | OnAllPluginsLoaded — Lifecycle |
hotReload constructor argument | previous() + OnPluginState — Lifecycle |
ISharedSystem.GetClientManager(), GetEntityManager(), … | Plain named imports from @s2script/sdk and @s2script/cs2 — API overview |
IConVarManager.CreateServerCommand | command.server — Commands |
IClientManager.InstallCommandCallback | command — Commands |
IClientManager.InstallCommandListener | command.onClientCommand — commands module |
ECommandAction.Stopped / Skipped | HookResult.Handled / HookResult.Continue — Commands |
IEventListener.FireGameEvent | hook.on — Events |
IEventListener.HookFireEvent (serverOnly = true) | hook.onPre returning HookResult.Handled — Events |
IClientListener (OnClientConnected, OnClientPostAdminCheck, …) | Named publics (OnClientConnected, OnClientPostAdminCheck, …) — Lifecycle |
IGameListener (OnResourcePrecache, OnGameActivate, …) | Named publics (OnPrecache, OnMapStart, OnMapEnd, OnGameFrame) — Lifecycle |
IModSharp.PushTimer / StopTimer | after / every / delay and Timer.kill() — timers module |
IModSharp.InvokeFrameAction | await nextFrame() — Async |
IGameClient.GetPlayerController() → GetPlayerPawn() | Player.fromSlot(slot)?.pawn or Pawn.forSlot(slot) — cs2 module |
IEntityManager.GetPlayerControllers() | Player.all() / Player.allConnected() — cs2 module |
IBaseEntity.IsValid(), GetNetVar / SetNetVar | Liveness-checked EntityRef and generated schema accessors — Entities, Schema fields |
IGameClient.Print(HudPrintChannel.Chat, …) / IModSharp.PrintToChatAll | Chat.toSlot / Chat.toAll / client.chat — chat module |
IConVarManager.CreateConVar / FindConVar | Server.registerCvar / Server.getCvar / Server.setCvar, or typed plugin config — server module |
AdminManager module: RegisterAdminCommand with permission strings | command.admin(name, ADMFLAG.X, handler) and Admin — admin module |
RegisterSharpModuleInterface (in PostInit) | publish (in OnPluginStart) — Interfaces |
GetRequiredSharpModuleInterface / GetOptionalSharpModuleInterface | use / watchOptional (or one-shot tryUse) — Interfaces |
OnLibraryConnected / OnLibraryDisconnect | The watchOptional attach callback and its scope — Interfaces |
dotnet publish to sharp/modules/{AssemblyName} | s2s build to a .s2sp, dropped into addons/s2script/plugins/ — Authoring |
Side by side#
Module skeleton and lifecycle#
A ModSharp module is one class with a fixed constructor signature. It registers in Init, publishes shared interfaces in PostInit, and undoes its registrations in Shutdown.
public sealed class Example : IModSharpModule
{
private readonly ISharedSystem _sharedSystem;
public Example(ISharedSystem sharedSystem, string dllPath, string sharpPath,
Version version, IConfiguration configuration, bool hotReload)
=> _sharedSystem = sharedSystem;
public bool Init()
{
Console.WriteLine("Hello, World!");
return true;
}
public void PostInit() { }
public void OnAllModulesLoaded() { }
public void Shutdown() => Console.WriteLine("Byebye, World!");
public string DisplayName => "Example";
public string DisplayAuthor => "YourName";
}An s2script plugin exports functions. There is no constructor and no service locator. The plugin’s name and version come from package.json.
import { previous } from '@s2script/sdk';
let reloads = 0;
export function OnPluginStart(): void {
// Init + PostInit: register commands, hooks, and interfaces here.
const prev = previous() as { reloads: number } | undefined;
reloads = (prev?.reloads ?? 0) + 1;
console.log('Hello, World!');
}
export function OnAllPluginsLoaded(): void {
// The plugin set is stable. This is not a second registration window.
}
export function OnPluginState(): unknown {
return { reloads }; // handed to the next instance as previous() on hot reload
}
export function OnPluginEnd(): void {
console.log('Byebye, World!');
}A command#
ModSharp splits commands across two managers, and you release each one in Shutdown.
public bool Init()
{
_sharedSystem.GetConVarManager()
.CreateServerCommand("ms_echo", OnServerCommand, "Echo", ConVarFlags.Release);
_sharedSystem.GetClientManager().InstallCommandCallback("hello", OnClientCommand);
return true;
}
public void Shutdown()
{
_sharedSystem.GetConVarManager().ReleaseServerCommandCallback("ms_echo", OnServerCommand);
_sharedSystem.GetClientManager().RemoveCommandCallback("hello", OnClientCommand);
}
private ECommandAction OnServerCommand(StringCommand command)
{
Console.WriteLine("Hello");
return ECommandAction.Stopped;
}
private ECommandAction OnClientCommand(IGameClient client, StringCommand command)
{
var name = command.ArgCount > 0 ? command.GetArg(1) : "YunLi";
client.ConsolePrint($"Hello, {name}");
return ECommandAction.Stopped;
}In s2script, command covers players and the console, and command.server is server-console only. Chat ! / / triggers reach the same registry. There is no release step.
import { command, HookResult } from '@s2script/sdk';
export function OnPluginStart(): void {
command.server('ms_echo', () => {
console.log('Hello');
return HookResult.Handled;
});
command('hello', (cmd) => {
const name = cmd.argCount > 0 ? cmd.arg(0) : 'YunLi'; // arg() is 0-based
cmd.replyToConsole(`Hello, ${name}`);
return HookResult.Handled;
});
}An event hook#
ModSharp delivers every event to one listener object. HookFireEvent runs before the event fires. Return false to block it, or set serverOnly to keep it from clients. FireGameEvent is the read-only listener.
public bool Init()
{
_sharedSystem.GetEventManager().InstallEventListener(this);
return true;
}
public void Shutdown() => _sharedSystem.GetEventManager().RemoveEventListener(this);
public void FireGameEvent(IGameEvent e)
{
if (e.Name.Equals("player_spawn"))
Console.WriteLine($"Player slot[{e.GetInt("userid")}] spawned");
}
public bool HookFireEvent(IGameEvent e, ref bool serverOnly)
{
if (e.Name.Equals("player_changename", StringComparison.OrdinalIgnoreCase))
serverOnly = true; // silence for clients
return true;
}
int IEventListener.ListenerVersion => IEventListener.ApiVersion;
int IEventListener.ListenerPriority => 0;s2script subscribes per event name. hook.on is post, and hook.onPre is pre. Returning HookResult.Handled from onPre is the serverOnly = true case: clients do not receive the event, and the server still processes it.
import { hook, HookResult } from '@s2script/sdk';
export function OnPluginStart(): void {
hook.on('player_spawn', (ev) => {
console.log(`Player slot[${ev.getPlayerSlot('userid')}] spawned`);
});
hook.onPre('player_changename', () => {
return HookResult.Handled; // silence for clients
});
}To change fields before broadcast, call a setter such as ev.setString('weapon', 'knife') in onPre and return HookResult.Changed. See Events.
A timer#
ModSharp timers take seconds and return a Guid. A Func<TimerAction> callback can return TimerAction.Stop to end a repeating timer.
var ticks = 0;
var id = _sharedSystem.GetModSharp().PushTimer(() =>
{
Console.WriteLine("tick");
return ++ticks >= 5 ? TimerAction.Stop : TimerAction.Continue;
}, 1.0, GameTimerFlags.Repeatable);
// elsewhere
_sharedSystem.GetModSharp().StopTimer(id);s2script timers take milliseconds. after runs once, every repeats, and both return a Timer you can kill(), including from inside its own callback. delay is the await form.
import { every, delay } from '@s2script/sdk/timers';
let ticks = 0;
const timer = every(1000, () => {
console.log('tick');
if (++ticks >= 5) timer.kill();
});
async function later(): Promise<void> {
await delay(2500);
console.log('2.5s later');
}Player and pawn access#
In ModSharp you go from the client to the controller to the pawn.
private void OnCommandKill(IGameClient? issuer, StringCommand cmd)
{
if (issuer?.GetPlayerController()?.GetPlayerPawn() is not { } pawn)
return;
if (!pawn.IsAlive)
{
pawn.Print(HudPrintChannel.Chat, "You are dead, you can't kill yourself.");
return;
}
pawn.Slay();
}In s2script, Player is the controller and Pawn is the body. Both are looked up by slot and return null when absent or stale.
import { command, Chat, HookResult } from '@s2script/sdk';
import { Player } from '@s2script/cs2';
export function OnPluginStart(): void {
command('kill', (cmd) => {
const pawn = Player.fromSlot(cmd.callerSlot)?.pawn; // or Pawn.forSlot(cmd.callerSlot)
if (!pawn) {
Chat.toSlot(cmd.callerSlot, "You are dead, you can't kill yourself.");
return HookResult.Handled;
}
pawn.slay();
return HookResult.Handled;
});
}Player.pawn is null when the player is dead or has no body. Go back from a pawn with pawn.controller.
Sharing an API between modules#
ModSharp shares a C# interface through a separate .Shared assembly. The provider registers it in PostInit. The consumer caches an IModSharpModuleInterface<T> and re-resolves it when the provider reconnects.
// SharedInterface.Shared
public interface IMySharedModule
{
const string Identity = nameof(IMySharedModule);
void CallMe();
}
// Provider
public void PostInit()
=> _modules.RegisterSharpModuleInterface<IMySharedModule>(this, IMySharedModule.Identity, this);
// Consumer
private IModSharpModuleInterface<IMySharedModule>? _cached;
public void OnAllModulesLoaded()
{
_cached = _modules.GetOptionalSharpModuleInterface<IMySharedModule>(IMySharedModule.Identity);
_cached?.Instance?.CallMe();
}In s2script the identity is the provider’s npm package name. The contract is a .d.ts file named by the package types field. Set interfaceProtocol to 2 on both provider and consumer.
{
"name": "@demo/counter",
"version": "1.0.0",
"types": "api.d.ts",
"main": "src/plugin.ts",
"s2script": { "interfaceProtocol": 2, "publishes": "self" }
}// api.d.ts
export interface Contract {
methods: { getCount(): number };
forwards: {};
}// provider: src/plugin.ts
import { publish } from '@s2script/sdk/plugin';
export function OnPluginStart(): void {
publish('@demo/counter', { getCount: () => 42 });
}A hard consumer lists the provider under s2script.pluginDependencies and calls use. An optional consumer lists it under s2script.optionalPluginDependencies and calls watchOptional, which does the job of OnLibraryConnected / OnLibraryDisconnect.
// consumer: src/plugin.ts
import { watchOptional } from '@s2script/sdk/plugin';
export function OnPluginStart(): void {
watchOptional('@demo/counter', (counter) => {
console.log(counter.getCount()); // runs again after each provider reload
});
}The consumer’s types come from the provider’s declaration: s2s build writes them to .s2script/interfaces.d.ts, which your tsconfig.json must include. Values cross between plugins by copy and are checked against the contract. See Wire values.
What’s different#
- No cleanup code. ModSharp docs require you to release commands, listeners, and hooks in
Shutdown. The s2script host ledger owns every command, event subscription, timer, and imported interface, and removes them on unload.OnPluginEndis for best-effort work only. - Registration has a window.
command,hook.on/hook.onPre,publish,use,watchOptional, andprevious()work only duringOnPluginStart. They throw after the plugin settles. Module top-level code runs before that window, so do not register there. - No dependency injection. There is no
ISharedSystemand noServiceCollection. Import what you need. Stateless helpers such asChat,Admin,config, andTranslationswork anywhere. - Units change. Timers take milliseconds, not seconds. Command arguments are 0-based (
cmd.arg(0)), where ModSharp’s first argument isGetArg(1). - One result type. Commands,
onPrehandlers, and say hooks all returnHookResult(Continue,Changed,Handled,Stop). Returning nothing meansContinue. - Admin is flag-based. s2script uses SourceMod-style bit flags (
ADMFLAG.KICK,ADMFLAG.SLAY, …) andAdmin.forSlot(slot)?.hasFlags(…), not permission strings such asadmin_offensive:slay. - Handles follow a connection. A
ClientorPlayerhandle belongs to one connection lifetime. After a reconnect, the same slot is a different handle. Re-resolve by slot oruserIdinstead of keeping old handles. - Client publics fire only for new connections. They fire for clients that connect after your plugin is active. To cover players already on the server, loop over
Clients.all()inOnPluginStart. - Plugins survive map changes.
OnPluginStartdoes not re-run per map. UseOnMapStartfor map-aware work.
Next#
- Getting started: install the runtime
- Authoring plugins: scaffold,
package.json, the typecheck gate - Plugin lifecycle: every named public, hot reload, map changes
- Inter-plugin interfaces: protocol 2, forwards,
watchOptional - API overview and the modules catalog