Development

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 submodule
mkdir 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 init

Add 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

src/plugin.cpp
#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:

  1. stores api, the plugin pointer and id in g_Source2ModAPI, g_PluginAPI and g_PluginID;
  2. fetches the core's KHook instance, so your hooks run on the same detour engine as Metamod and every other plugin;
  3. 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 … into error and returns false from Load();
  4. 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

MacroOutput
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

CMakeLists.txt
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 Release

Build 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 hello

Check it:

s2m list
hello

s2m 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.

Next steps

On this page