Development

Coming from CounterStrikeSharp

What a CounterStrikeSharp plugin turns into on Source2Mod — the same concepts side by side, a full plugin ported, and what you get for switching.

If you have written CounterStrikeSharp plugins, you already know most of Source2Mod. Both sit on Metamod:Source, both expose the game's schema classes (CCSPlayerController, CCSPlayerPawn, …), both hook game events, register commands and run timers. The names are often the same.

What changes is the language: plugins are native C++ built against the SDK, not C# on .NET. This page maps one onto the other. For the language side — pointers, lifetimes, strings, lambdas — read C++ for C# developers next to it.

Why switch

  • Plugins survive CS2 updates. A plugin on the stable tier ships no signatures and no offsets of its own; the core owns them. After a Valve update you update the core, and the plugins keep running unchanged.
  • Crashes point at your line. The crash handler symbolizes plugin frames to function @ file:line and names a suspected culprit, on the server itself, optionally straight into Discord.
  • No managed runtime. No .NET to install, no GC pauses, no marshalling between managed and native code. Your code calls the game directly.
  • Network state just works. pPawn->m_iHealth = 150; announces the change to clients by itself — there is no Utilities.SetStateChanged to forget.
  • Everything is cleaned up for you. Commands, hooks, timers, menus and listeners belong to the plugin that registered them and are removed when it unloads. On Linux, copying a new .smx over the old one hot reloads it.
  • Your license, your choice. Source2Mod is GPLv3 with an MIT exception for derivative works: plugins can be open source, closed source or commercial. See the FAQ.

The concepts side by side

Plugin structure

CounterStrikeSharpSource2Mod
class MyPlugin : BasePluginclass MyPlugin final : public ISource2ModPlugin
ModuleName, ModuleVersion, ModuleAuthor, ModuleDescriptionGetName(), GetVersion(), GetAuthor(), GetDescription()
Load(bool hotReload)Load(PluginId id, ISource2ModAPI* api, char* error, size_t maxlen, bool late) — first line SOURCE2MOD_SAVEVARS();
Unload(bool hotReload)Unload(char* error, size_t maxlen)
Throwing in Load to refuse loadingWrite the reason into error, return false;
The plugin class is found by reflectionSOURCE2MOD_EXPOSE(name, g_Plugin); once, at the bottom of one .cpp
.dll in plugins/MyPlugin/.smx in addons/source2mod/plugins/
css_plugins reload MyPlugins2m unload <id> + s2m load <name> (s2m list shows the ids); on Linux, copying the new .smx over the old one hot reloads it
Logger.LogInformation(...)SOURCE2MOD_LOG(this, "...\n", ...) (printf-style)

Commands

CounterStrikeSharpSource2Mod
[ConsoleCommand("css_hello")] / AddCommand("css_hello", ...)REGISTER_CON_COMMAND("hello", handler) — !hello and /hello in chat reach it too
CCSPlayerController? player (null = console)CCSPlayerController::FromSlot(context.GetPlayerSlot()) (nullptr = console)
command.GetArg(1), command.ArgString, command.ArgCountargs.Arg(1), args.ArgS(), args.ArgC() — see Commands
AddCommandListener("jointeam", ..., HookMode.Pre)REGISTER_CON_LISTENER("jointeam", handler, false) — see Command listeners
[RequiresPermissions("@css/kick")]REGISTER_CON_COMMAND("kick", handler, "myplugin.admin.kick") — see Permissions
command.ReplyToCommand(...)REPLY_TO_COMMAND(ctx, ...) — to chat or console, wherever it was typed
AdminManager.PlayerHasPermissions(player, "@css/kick")PERMISSIONS_PLAYER_HAS(slot, "myplugin.admin.kick")
AdminManager.AddPlayerPermissions(steamId, ...)PERMISSIONS_GRANT(steamId, ...) / PERMISSIONS_ADD_TO_GROUP(steamId, ...)
admins.json, admin_groups.json, admin_overrides.jsonconfigs/permissions.json (Admins, Groups, Overrides)

Events and listeners

CounterStrikeSharpSource2Mod
[GameEventHandler] OnPlayerDeath(EventPlayerDeath @event, GameEventInfo info)HOOK_GAME_EVENT("player_death", handler, true)
RegisterEventHandler<EventPlayerDeath>(..., HookMode.Pre)HOOK_GAME_EVENT("player_death", handler, false)
@event.Attacker, @event.Weaponevent->GetPlayerController("attacker"), event->GetString("weapon")
info.DontBroadcast = truedontBroadcast = true; (a bool& parameter)
HookResult.ContinueAction::Ignore
HookResult.Stop / HookResult.HandledAction::Supersede
RegisterListener<Listeners.OnMapStart>OnLevelInit(...) on your ISource2ModListener
Listeners.OnMapEndOnLevelShutdown()
Listeners.OnClientPutInServerOnClientPutInServer(slot, name, type, xuid)
Listeners.OnClientDisconnectOnClientDisconnect(slot, reason, name, xuid, networkId)
Listeners.OnTickOnGameFrame(simulating, firstTick, lastTick)
Listeners.OnEntityCreated / OnEntitySpawnedOnEntityCreated / OnEntitySpawned — see Finding entities

Listener callbacks are virtual methods: inherit ISource2ModListener, override the ones you want and call api->AddListener(this, this) in Load(). The full list is in Plugin lifecycle.

Players and entities

CounterStrikeSharpSource2Mod
Utilities.GetPlayerFromSlot(slot)CCSPlayerController::FromSlot(slot)
Utilities.GetPlayerFromUserid(id)CCSPlayerController::FromUserId(id)
Utilities.GetPlayerFromSteamId(id)CCSPlayerController::FromSteamId(id)
Utilities.GetPlayers()A loop over the slots — see Iterating players
player.IsValidA null check, plus m_iConnected() when it matters
player.PlayerPawn.Valueplayer->GetPlayerPawn()
player.PawnIsAliveplayer->m_bPawnIsAlive()
pawn.Health = 150; + Utilities.SetStateChanged(...)pPawn->m_iHealth = 150; — announced by itself
pawn.Health (read)pPawn->m_iHealth()
player.PrintToChat(...), PrintToCenter(...), PrintToCenterHtml(...)The same names on the controller
player.Respawn(), player.SwitchTeam(...), pawn.CommitSuicide(...)The same names — see Players
player.GiveNamedItem("weapon_ak47")GiveNamedItem on the pawn's item services
CHandle<T> / .ValueCHandle<T> / .Get()
Utilities.CreateEntityByName<T>(...)See Creating entities
entity.AcceptInput("Kill")AcceptInput(...), or Remove()

Fields keep their schema names: CounterStrikeSharp's pawn.ArmorValue is pPawn->m_ArmorValue(), controller.Score is m_iScore(). The schema reference lists every class and field.

Timers, ConVars, hooks, services

CounterStrikeSharpSource2Mod
AddTimer(1.0f, ...)ADD_TIMER(1.0f, ...)
TimerFlags.REPEAT, TimerFlags.STOP_ON_MAPCHANGETIMER_FLAG_REPEAT, TIMER_FLAG_NO_MAPCHANGE
timer.Kill()KILL_TIMER(timer)
Server.NextFrame(...)NEXT_FRAME(...)
FakeConVar<int>CConVar<int> at file scope — see ConVars
ConVar.Find("mp_limitteams")CConVarRef<int> mp_limitteams("mp_limitteams");
VirtualFunctions.CBaseEntity_TakeDamageOldFunc.Hook(...)g_pSource2ModGameHooks->HookTakeDamage(...) — no signature of your own, see Game hooks
MemoryFunctionVoid<...> + a signatureGamedata and a function hook
HookUserMessage(...)Network messages
CenterHtmlMenu + MenuManager.OpenCenterHtmlMenu(...)CenterHtmlMenu + OPEN_CENTER_HTML_MENU(player, &menu) — see Menus
IPluginConfig<T> (JSON config)PLUGIN_CONFIG_DIR() + JSON
HttpClientHTTP — callbacks on the main thread
MySqlConnector / DapperSQL — queries off the main thread, results on it

A plugin, ported

A small plugin in both: every kill heals the killer by a ConVar's amount (capped at 100), and !hp tells a player their health.

KillHeal.cs
using CounterStrikeSharp.API;
using CounterStrikeSharp.API.Core;
using CounterStrikeSharp.API.Core.Attributes.Registration;
using CounterStrikeSharp.API.Modules.Commands;
using CounterStrikeSharp.API.Modules.Cvars;

public class KillHeal : BasePlugin
{
    public override string ModuleName => "Kill Heal";
    public override string ModuleVersion => "1.0.0";
    public override string ModuleAuthor => "You";
    public override string ModuleDescription => "Heals the killer";

    public FakeConVar<int> HealAmount = new("killheal_amount", "HP given per kill", 10);

    [GameEventHandler]
    public HookResult OnPlayerDeath(EventPlayerDeath @event, GameEventInfo info)
    {
        var attacker = @event.Attacker;
        if (attacker == null || !attacker.IsValid || attacker == @event.Userid || !attacker.PawnIsAlive)
            return HookResult.Continue;

        var pawn = attacker.PlayerPawn.Value;
        if (pawn == null)
            return HookResult.Continue;

        pawn.Health = Math.Min(pawn.Health + HealAmount.Value, 100);
        Utilities.SetStateChanged(pawn, "CBaseEntity", "m_iHealth");
        return HookResult.Continue;
    }

    [ConsoleCommand("css_hp", "Shows your health")]
    public void OnHp(CCSPlayerController? player, CommandInfo command)
    {
        var pawn = player?.PlayerPawn.Value;
        if (pawn != null)
            player!.PrintToChat($"You have {pawn.Health} HP.");
    }
}
src/plugin.cpp
#include "source2mod/ISource2ModPlugin.h"
#include "source2mod/ISource2ModApi.h"
#include "source2mod/schema/entity/classes/CCSPlayerController.h"
#include "source2mod/schema/entity/classes/CCSPlayerPawn.h"

#include "igameevents.h"

#include <algorithm>
#include <string>

SOURCE2MOD_GLOBALVARS();

// FakeConVar<int> -> a CConVar at file scope, registered in Load().
CConVar<int> killheal_amount("killheal_amount", FCVAR_NONE, "HP given per kill", 10);

class KillHeal final : public ISource2ModPlugin
{
public:
    bool Load(PluginId id, ISource2ModAPI* api, char* error, size_t maxlen, bool late) override
    {
        SOURCE2MOD_SAVEVARS();

        g_pCVar = api->GetCVar();
        ConVar_Register(FCVAR_RELEASE | FCVAR_CLIENT_CAN_EXECUTE | FCVAR_GAMEDLL);

        // [GameEventHandler] -> HOOK_GAME_EVENT, post = true.
        HOOK_GAME_EVENT("player_death", [](IGameEvent* event, bool post, bool& dontBroadcast) -> Action
        {
            auto* pVictim   = static_cast<CCSPlayerController*>(event->GetPlayerController("userid"));
            auto* pAttacker = static_cast<CCSPlayerController*>(event->GetPlayerController("attacker"));

            if (!pAttacker || pAttacker == pVictim || !pAttacker->m_bPawnIsAlive())
                return Action::Ignore;

            CCSPlayerPawn* pPawn = pAttacker->GetPlayerPawn();
            if (!pPawn)
                return Action::Ignore;

            // Assigning the field is what sends it to clients -- no SetStateChanged.
            pPawn->m_iHealth = std::min(pPawn->m_iHealth() + killheal_amount.Get(), 100);
            return Action::Ignore;
        }, true);

        // [ConsoleCommand("css_hp")] -> "hp", reachable as !hp and /hp.
        REGISTER_CON_COMMAND("hp", [](const Source2ModCommandContext& context, const Source2ModCommandArgs& args, bool post)
        {
            CCSPlayerController* pPlayer = CCSPlayerController::FromSlot(context.GetPlayerSlot());
            if (!pPlayer)
                return; // the server console

            CCSPlayerPawn* pPawn = pPlayer->GetPlayerPawn();
            if (!pPawn)
                return;

            const std::string message = "You have " + std::to_string(pPawn->m_iHealth()) + " HP.";
            pPlayer->PrintToChat(message.c_str());
        });

        return true;
    }

    bool Unload(char* error, size_t maxlen) override
    {
        // The event hook and the command are removed by Source2Mod.
        // ConVars are the one thing every plugin unregisters itself.
        ConVar_Unregister();
        return true;
    }

    const char* GetName() override { return "Kill Heal"; }
    const char* GetVersion() override { return "1.0.0"; }
    const char* GetAuthor() override { return "You"; }
    const char* GetDescription() override { return "Heals the killer"; }
};

KillHeal g_Plugin;
SOURCE2MOD_EXPOSE(killheal, g_Plugin);

Line for line, the two do the same thing. The differences worth noticing:

  • Null checks are yours. There is no IsValid wrapper and no NullReferenceException — a null pointer you call through crashes the server. Every lookup (FromSlot, GetPlayerPawn, GetPlayerController) can return nullptr.
  • Reading a field is a call. m_iHealth() reads, m_iHealth = x writes and announces. See Schema fields.
  • Strings are const char* at the API boundary. Build a std::string, pass .c_str(). See Strings.
  • as / is is dynamic_cast, which works on game types, or static_cast when you already know the type — see Casting.
  • ConVars are unregistered in Unload(). Everything else is cleaned up by Source2Mod.

Running both on one server

Source2Mod and CounterStrikeSharp are separate Metamod plugins, and since CounterStrikeSharp v1.0.375 both hook through the same KHook instance in Metamod, so a server can load both while you move plugins over one at a time. What to know:

  • CounterStrikeSharp v1.0.375 or newer. Older builds use SourceHook, which the KHook Metamod Source2Mod needs no longer has; Metamod refuses to load them.
  • Chat triggers. Give the two frameworks different silent triggers (SilentChatTrigger in Source2Mod's core.json). Source2Mod swallows every silent chat message before the game's say code runs, so CounterStrikeSharp never sees a /command that uses the same prefix.
  • Blocking the same event twice. Do not cancel the same game event in a CounterStrikeSharp plugin and a s2m plugin.
  • Windows: a server with CounterStrikeSharp needs -DoNotPreloadDLLs for Source2Mod's crash handler — see Crash handler.

Next steps

  1. Set up the SDK: SDK & Building.
  2. Build your first plugin.
  3. Read C++ for C# developers — the short list of things C# never made you think about.
  4. Look at samples/cs2_sample in the core repository: commands, events, menus, timers, hooks and ConVars in one plugin.
  5. Stuck? Ask in #plugin-developers or open a post in #help on the Discord.

On this page