Your first C++ plugin
A minimal Source2Mod plugin, from the plugin class to a built .smx on the server — with a command, a game event and logging.
This page builds a complete, small plugin: it logs when it loads, answers a hello command, and prints a line whenever a player dies. Everything here is the same pattern the official sample plugin (samples/cs2_sample in the core repository) uses, just with less of it.
Before you start, set up the SDK and its dependencies as described in SDK & Building.
Project layout
hello/
├── CMakeLists.txt
├── src/
│ └── plugin.cpp
└── vendor/
└── source2mod-sdk/ # git submodulemkdir hello && cd hello && git init
git submodule add https://github.com/Source2Mod/source2mod-sdk.git vendor/source2mod-sdk
git submodule update --init --recursive
python vendor/source2mod-sdk/tools/deps.py initAdd the SDK with git, not from a downloaded zip. The build reads the KHook commit from vendor/khook's git metadata; without it the plugin carries unknown and the KHook version check cannot protect you.
The plugin
#include "source2mod/ISource2ModPlugin.h"
#include "source2mod/ISource2ModApi.h"
#include "source2mod/schema/entity/classes/CCSPlayerController.h"
#include "igameevents.h"
// Declares g_Source2ModAPI, g_PluginID and the g_pSource2Mod* interface pointers.
// The class below uses them, so they have to be declared before it;
// SOURCE2MOD_EXPOSE at the bottom defines them.
SOURCE2MOD_GLOBALVARS();
class HelloPlugin final : public ISource2ModPlugin, public ISource2ModListener
{
public:
bool Load(PluginId id, ISource2ModAPI* api, char* error, size_t maxlen, bool late) override
{
// First line of every Load(): stores the API and plugin id, checks the
// KHook version, fills the g_pSource2Mod* pointers.
SOURCE2MOD_SAVEVARS();
// Receive the ISource2ModListener callbacks below.
api->AddListener(this, this);
// A console command. "!hello" and "/hello" in chat reach it too.
REGISTER_CON_COMMAND("hello", [](const Source2ModCommandContext& context, const Source2ModCommandArgs& args, bool post)
{
const CPlayerSlot slot = context.GetPlayerSlot();
// No valid slot means the server console ran it.
if (!slot.IsValid())
{
SOURCE2MOD_CONPRINTF("Hello, console!\n");
return;
}
if (CCSPlayerController* pPlayer = CCSPlayerController::FromSlot(slot))
pPlayer->PrintToChat("Hello from my first plugin!");
});
// A game event, after the engine has handled it (post = true).
HOOK_GAME_EVENT("player_death", [this](IGameEvent* event, bool post, bool& dontBroadcast) -> Action
{
auto* pVictim = static_cast<CCSPlayerController*>(event->GetPlayerController("userid"));
auto* pAttacker = static_cast<CCSPlayerController*>(event->GetPlayerController("attacker"));
SOURCE2MOD_LOG(this, "%s killed %s with %s\n",
pAttacker ? pAttacker->GetPlayerName() : "world",
pVictim ? pVictim->GetPlayerName() : "?",
event->GetString("weapon"));
return Action::Ignore;
}, true);
SOURCE2MOD_LOG(this, "Loaded (id %d)%s.\n", id, late ? ", hot reload" : "");
return true;
}
bool Unload(char* error, size_t maxlen) override
{
// The command, the event hook and the listener belong to this plugin
// (the core reads that off the handlers); Source2Mod removes them on
// unload. Nothing else to undo here.
SOURCE2MOD_LOG(this, "Unloaded.\n");
return true;
}
// ISource2ModListener
void OnAllSource2ModPluginsLoaded() override
{
SOURCE2MOD_LOG(this, "All Source2Mod plugins are loaded.\n");
}
void OnLevelInit(const char* mapName, const char* mapEntities, const char* oldLevel,
const char* landmarkName, bool loadGame, bool background) override
{
SOURCE2MOD_LOG(this, "Map %s is starting.\n", mapName);
}
// ISource2ModPlugin metadata, shown by "s2m list" and "s2m info".
const char* GetAuthor() override { return "You"; }
const char* GetName() override { return "Hello"; }
const char* GetDescription() override { return "My first Source2Mod plugin"; }
const char* GetVersion() override { return "1.0.0"; }
};
HelloPlugin g_Plugin;
SOURCE2MOD_EXPOSE(hello, g_Plugin);What each piece does
ISource2ModPlugin is the one interface every plugin implements. Load() and the four metadata getters are pure virtual; Unload() has a default that returns true.
virtual bool Load(PluginId id, ISource2ModAPI* api, char* error, size_t maxlen, bool late) = 0;
virtual bool Unload(char* error, size_t maxlen);Return false from Load() to refuse loading, with a reason written into error (at most maxlen bytes). The core logs Plugin <name> refused to load: <reason> and removes whatever the plugin registered before it gave up. late is true when the plugin is being hot reloaded.
SOURCE2MOD_EXPOSE(name, var) goes in exactly one .cpp file. It defines Source2Mod globals (g_Source2ModAPI, g_PluginID, g_pSource2ModCommands, …, and KHook's engine pointer) and exports var as the plugin interface the core looks for.
SOURCE2MOD_SAVEVARS() must be the first statement of Load(), and Load()'s parameters must keep their declared names (id, api, error, maxlen), because the macro uses them. It:
- stores
api, the plugin pointer andiding_Source2ModAPI,g_PluginAPIandg_PluginID; - fetches the core's KHook instance, so your hooks run on the same detour engine as Metamod and every other plugin;
- compares the KHook commit your plugin was built against with the one the core runs, and on a mismatch writes
KHook mismatch: plugin built against …, s2m core runs …intoerrorand returnsfalsefromLoad(); - fills every
g_pSource2Mod*interface pointer.
If either side cannot report its KHook commit (an older core, or a plugin built without git), the check is skipped and a note is written into error instead; the load goes on.
SOURCE2MOD_GLOBALVARS() declares the same globals as extern. Put it at the top of every other translation unit that uses them — see Plugin lifecycle.
ISource2ModListener gives you lifecycle callbacks; every method has an empty default, so override only what you need. It only receives them after api->AddListener(this, listener). The full list is on the Plugin lifecycle page.
Logging
| Macro | Output |
|---|---|
SOURCE2MOD_LOG(plugin, fmt, ...) | [<plugin name>] <message> to the server console. Inside the class pass this; elsewhere pass &g_Plugin. |
SOURCE2MOD_CONPRINTF(fmt, ...) | A formatted line to the server console, no prefix. |
SOURCE2MOD_CONPRINT(msg) | An unformatted string to the server console. |
None of them add a newline — end your format strings with \n. They go through Metamod's console output, so they show up wherever the server console goes (the terminal, -condebug logs, RCON output).
Commands and events
REGISTER_CON_COMMAND(name, handler) registers a console command owned by your plugin; there is no plugin id to pass, the core reads the owner off the handler. The handler is a lambda, a free or static function, or an object and a method (SOURCE2MOD_MEMBER(this, &HelloPlugin::OnHello)), with the signature void(const Source2ModCommandContext&, const Source2ModCommandArgs&, bool post). The same handler runs for !name and /name in chat (the triggers are set in core.json). See ConVars & Commands and the ISource2ModCommands reference.
HOOK_GAME_EVENT(name, handler, post) hooks a game event. The handler returns an Action: in a pre hook (post = false) you may change fields, set dontBroadcast, or return Action::Supersede to cancel the event; in a post hook you only react. See Events and the ISource2ModEvents reference.
CMake
cmake_minimum_required(VERSION 3.18)
project(hello CXX)
set(CMAKE_CXX_STANDARD 20)
# Windows: match the SDK's static MSVC runtime.
set(CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreaded")
add_subdirectory(vendor/source2mod-sdk)
add_source2mod_plugin(hello src/plugin.cpp)
# Optional: put the .smx straight into an addons/ tree you can copy to a server.
set_target_properties(hello PROPERTIES
LIBRARY_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/addons/source2mod/plugins
RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/addons/source2mod/plugins # Windows
)add_source2mod_plugin creates a shared library linked against the SDK and names it <name>.smx (no lib prefix) on both platforms. The core only loads files with the .smx extension.
Build and install
Build:
cmake -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build --config ReleaseBuild on Linux for a Linux server and with MSVC on Windows for a Windows server; a .smx is a native library for one platform.
Copy hello.smx to game/csgo/addons/source2mod/plugins/ on the server.
Restart the server, or load it on the running server from the console:
s2m load helloCheck it:
s2m list
hellos2m list shows [<id>] Hello (1.0.0) by You, and hello prints Hello, console!.
If the plugin does not show up, the server console says why — look for refused to load or Failed to load.