Search docs
Search guides, modules and symbols
From ModSharp

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#

ModSharps2script
Class implementing IModSharpModuleA 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 argumentprevious() + OnPluginState — Lifecycle
ISharedSystem.GetClientManager(), GetEntityManager(), …Plain named imports from @s2script/sdk and @s2script/cs2 — API overview
IConVarManager.CreateServerCommandcommand.server — Commands
IClientManager.InstallCommandCallbackcommand — Commands
IClientManager.InstallCommandListenercommand.onClientCommand — commands module
ECommandAction.Stopped / SkippedHookResult.Handled / HookResult.Continue — Commands
IEventListener.FireGameEventhook.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 / StopTimerafter / every / delay and Timer.kill() — timers module
IModSharp.InvokeFrameActionawait 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 / SetNetVarLiveness-checked EntityRef and generated schema accessors — Entities, Schema fields
IGameClient.Print(HudPrintChannel.Chat, …) / IModSharp.PrintToChatAllChat.toSlot / Chat.toAll / client.chat — chat module
IConVarManager.CreateConVar / FindConVarServer.registerCvar / Server.getCvar / Server.setCvar, or typed plugin config — server module
AdminManager module: RegisterAdminCommand with permission stringscommand.admin(name, ADMFLAG.X, handler) and Admin — admin module
RegisterSharpModuleInterface (in PostInit)publish (in OnPluginStart) — Interfaces
GetRequiredSharpModuleInterface / GetOptionalSharpModuleInterfaceuse / watchOptional (or one-shot tryUse) — Interfaces
OnLibraryConnected / OnLibraryDisconnectThe 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. OnPluginEnd is for best-effort work only.
  • Registration has a window. command, hook.on / hook.onPre, publish, use, watchOptional, and previous() work only during OnPluginStart. 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 ISharedSystem and no ServiceCollection. Import what you need. Stateless helpers such as Chat, Admin, config, and Translations work anywhere.
  • Units change. Timers take milliseconds, not seconds. Command arguments are 0-based (cmd.arg(0)), where ModSharp’s first argument is GetArg(1).
  • One result type. Commands, onPre handlers, and say hooks all return HookResult (Continue, Changed, Handled, Stop). Returning nothing means Continue.
  • Admin is flag-based. s2script uses SourceMod-style bit flags (ADMFLAG.KICK, ADMFLAG.SLAY, …) and Admin.forSlot(slot)?.hasFlags(…), not permission strings such as admin_offensive:slay.
  • Handles follow a connection. A Client or Player handle belongs to one connection lifetime. After a reconnect, the same slot is a different handle. Re-resolve by slot or userId instead 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() in OnPluginStart.
  • Plugins survive map changes. OnPluginStart does not re-run per map. Use OnMapStart for map-aware work.

Next#