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:lineand 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 noUtilities.SetStateChangedto 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
.smxover 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
| CounterStrikeSharp | Source2Mod |
|---|---|
class MyPlugin : BasePlugin | class MyPlugin final : public ISource2ModPlugin |
ModuleName, ModuleVersion, ModuleAuthor, ModuleDescription | GetName(), 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 loading | Write the reason into error, return false; |
| The plugin class is found by reflection | SOURCE2MOD_EXPOSE(name, g_Plugin); once, at the bottom of one .cpp |
.dll in plugins/MyPlugin/ | .smx in addons/source2mod/plugins/ |
css_plugins reload MyPlugin | s2m 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
| CounterStrikeSharp | Source2Mod |
|---|---|
[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.ArgCount | args.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.json | configs/permissions.json (Admins, Groups, Overrides) |
Events and listeners
| CounterStrikeSharp | Source2Mod |
|---|---|
[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.Weapon | event->GetPlayerController("attacker"), event->GetString("weapon") |
info.DontBroadcast = true | dontBroadcast = true; (a bool& parameter) |
HookResult.Continue | Action::Ignore |
HookResult.Stop / HookResult.Handled | Action::Supersede |
RegisterListener<Listeners.OnMapStart> | OnLevelInit(...) on your ISource2ModListener |
Listeners.OnMapEnd | OnLevelShutdown() |
Listeners.OnClientPutInServer | OnClientPutInServer(slot, name, type, xuid) |
Listeners.OnClientDisconnect | OnClientDisconnect(slot, reason, name, xuid, networkId) |
Listeners.OnTick | OnGameFrame(simulating, firstTick, lastTick) |
Listeners.OnEntityCreated / OnEntitySpawned | OnEntityCreated / 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
| CounterStrikeSharp | Source2Mod |
|---|---|
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.IsValid | A null check, plus m_iConnected() when it matters |
player.PlayerPawn.Value | player->GetPlayerPawn() |
player.PawnIsAlive | player->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> / .Value | CHandle<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
| CounterStrikeSharp | Source2Mod |
|---|---|
AddTimer(1.0f, ...) | ADD_TIMER(1.0f, ...) |
TimerFlags.REPEAT, TimerFlags.STOP_ON_MAPCHANGE | TIMER_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 signature | Gamedata 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 |
HttpClient | HTTP — callbacks on the main thread |
| MySqlConnector / Dapper | SQL — 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.
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.");
}
}#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
IsValidwrapper and noNullReferenceException— a null pointer you call through crashes the server. Every lookup (FromSlot,GetPlayerPawn,GetPlayerController) can returnnullptr. - Reading a field is a call.
m_iHealth()reads,m_iHealth = xwrites and announces. See Schema fields. - Strings are
const char*at the API boundary. Build astd::string, pass.c_str(). See Strings. as/isisdynamic_cast, which works on game types, orstatic_castwhen 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
(
SilentChatTriggerin Source2Mod'score.json). Source2Mod swallows every silent chat message before the game's say code runs, so CounterStrikeSharp never sees a/commandthat 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
-DoNotPreloadDLLsfor Source2Mod's crash handler — see Crash handler.
Next steps
- Set up the SDK: SDK & Building.
- Build your first plugin.
- Read C++ for C# developers — the short list of things C# never made you think about.
- Look at
samples/cs2_samplein the core repository: commands, events, menus, timers, hooks and ConVars in one plugin. - Stuck? Ask in
#plugin-developersor open a post in#helpon the Discord.
SourcePawn plugins
The same API as C++, as a SourcePawn script — .smx files compiled with spcomp, loaded next to native plugins.
C++ for C# developers
The part of C++ a plugin actually needs, explained from C# — pointers and lifetimes, strings, lambdas, collections, casting, headers, and what a crash tells you.