Guides

Clients and targeting

Player state by slot — in game, bot, Steam-validated, SteamIDs in every form, latency, client cvars, kicking — and turning command text like @ct,

Two small interfaces that admin-style commands use together:

  • ISource2ModClients — what the controller classes do not have: whether a slot is in game or validated, the SteamID in every form, latency, client cvars, kicking with a logged reason.
  • ISource2ModTargeting — turns what a player typed (@ct, #12, STEAM_1:0:123, bob) into player slots, checking immunity on the way.
plugin.cpp
#include "source2mod/ISource2ModClients.h"
#include "source2mod/ISource2ModTargeting.h"

SourcePawn has them as Source2ModClients and Source2ModTargeting in <source2mod/api>.

What a player controller already does — name, IP, SteamID64, user id, console commands, team changes, Disconnect — stays on the controller.

Clients

Every call takes a player slot (0–63). An empty or out-of-range slot is simply not connected; nothing throws.

Call
IsConnected(slot)The slot has a client, connecting or in game.
IsInGame(slot)The client is fully in game.
IsFakeClient(slot)A bot.
IsAuthorized(slot)Steam has validated the client's SteamID. Never for a bot.
GetAuthId(slot, form, validated = true)The SteamID as text. Empty for a bot, an empty slot, or — with validated — one Steam has not validated yet.
GetLatency(slot)Average round trip in seconds; 0 for a bot.
GetConnectionTime(slot)Seconds connected; 0 for a bot.
GetClientConVar(slot, name)A cvar the client sent (cl_language, ...); empty when it did not.
GetClientCount(inGameOnly = true)Clients on the server, bots included. false also counts those still connecting.
Kick(slot, message)Disconnects the client.

The SteamID forms (SourceMod's AuthIdType):

C++SourcePawnExample
SOURCE2MOD_AUTHID_ENGINEAuthIdForm_Engineas the engine shows it (Steam2)
SOURCE2MOD_AUTHID_STEAM2AuthIdForm_Steam2STEAM_1:0:12345
SOURCE2MOD_AUTHID_STEAM3AuthIdForm_Steam3[U:1:24690]
SOURCE2MOD_AUTHID_STEAMID64AuthIdForm_SteamID6476561197960290418
plugin.cpp
REGISTER_CON_COMMAND("whois", [](const Source2ModCommandContext& ctx, const Source2ModCommandArgs& args, bool post)
{
    auto* cl = g_pSource2ModClients;
    for (int i = 0; i < 64; i++)
    {
        const CPlayerSlot slot(i);
        if (!cl->IsConnected(slot) || cl->IsFakeClient(slot))
            continue;

        CCSPlayerController* player = CCSPlayerController::FromSlot(slot);
        const std::string steam2 = cl->GetAuthId(slot, SOURCE2MOD_AUTHID_STEAM2, false);
        const std::string lang = cl->GetClientConVar(slot, "cl_language");

        REPLY_TO_COMMAND(ctx, "#%d %s%s ping %.0f ms, %.0f s, %s",
            player ? player->GetUserID() : -1, steam2.c_str(),
            cl->IsAuthorized(slot) ? "" : " (not validated)",
            cl->GetLatency(slot) * 1000.0f, cl->GetConnectionTime(slot), lang.c_str());
    }
});

Validated or claimed

A client claims a SteamID when it connects; Steam validates it a moment later. GetAuthId(..., validated = true) — the default — returns nothing until then, so a ban check or an admin lookup is not fooled by a claimed SteamID. validated = false gives the claimed one right away, for display.

The place to check a SteamID is the moment Steam validates it: OnClientAuthorized.

plugin.cpp
void MyPlugin::OnClientAuthorized(CPlayerSlot slot, uint64 steamId)
{
    const std::string steam2 = g_pSource2ModClients->GetAuthId(slot, SOURCE2MOD_AUTHID_STEAM2);
    if (IsBanned(steam2))
    {
        const std::string message = "banned (" + steam2 + ")";
        g_pSource2ModClients->Kick(slot, message.c_str());
    }
}

Which SteamID permissions check a player under is a separate setting, SteamAuthMode.

Kicking

Kick disconnects with the engine's "kicked" reason. CS2 shows the player that reason's own text; the message is what the server logs. In SourcePawn Kick formats (%s, %d, %T, ...).

SourcePawn only

C++ reads these off the controller (GetUserID(), GetSteamID()) or loops over the slots; SourcePawn has them as calls:

Call
ConvertSteamId(steamId, form, buffer, maxlen)Any SteamID form into another. false for text that is none of them.
GetUserId(slot)The user id (what #<userid> targets); -1 for no client.
FromUserId(userid)The slot of a connected client by user id; CPlayerSlot(-1) for none.
FromSteamId(steamId)The slot of a connected client by SteamID in any form; CPlayerSlot(-1) for none.
plugin.sp
char id64[32];
if (Source2ModClients.ConvertSteamId("STEAM_1:1:12345", AuthIdForm_SteamID64, id64, sizeof(id64)))
	Source2ModApi.Log("%s", id64); // 76561197960290419

CPlayerSlot bySteam = Source2ModClients.FromSteamId("[U:1:24691]"); // any form
CPlayerSlot byUserId = Source2ModClients.FromUserId(12);

Targeting

ProcessTargetString(pattern, caller, flags, ...) finds the players a pattern means. caller is who is targeting (for immunity, @me, @aim); -1 is the server console.

PatternMeans
@all, @t, @ct, @specEverybody, a team
@alive, @dead, @bots, @humansBy state
@me, @!meThe caller; everybody but the caller
@aimThe player under the caller's crosshair
@<filter>A filter a plugin added (see Filters of your own)
#<userid>One player by user id: #12
a SteamIDAny form, with or without #: STEAM_1:0:1, #[U:1:2], 7656119...
#<name>A player whose name is exactly this
anything elsePart of a name, case-insensitive. A name that matches exactly wins over partial matches.

@ names are case-insensitive. An @ name nobody knows matches nobody.

Flags

SourceMod's COMMAND_FILTER_*, or-ed together:

C++SourcePawn
SOURCE2MOD_TARGET_ALIVETarget_AliveOnly alive players
SOURCE2MOD_TARGET_DEADTarget_DeadOnly dead players
SOURCE2MOD_TARGET_CONNECTEDTarget_ConnectedAlso players not fully in game
SOURCE2MOD_TARGET_NO_IMMUNITYTarget_NoImmunitySkip the immunity check
SOURCE2MOD_TARGET_NO_MULTITarget_NoMultiOne player only; @ patterns are refused
SOURCE2MOD_TARGET_NO_BOTSTarget_NoBotsNo bots

Results

A result above 0 is the number of players. Otherwise it is an error (SourceMod's COMMAND_TARGET_*):

C++SourcePawnPhrase in common.phrases
SOURCE2MOD_TARGET_NONE (0)Target_NoneNo matching client
SOURCE2MOD_TARGET_NOT_ALIVETarget_NotAliveTarget must be alive
SOURCE2MOD_TARGET_NOT_DEADTarget_NotDeadTarget must be dead
SOURCE2MOD_TARGET_NOT_IN_GAMETarget_NotInGameTarget is not in game
SOURCE2MOD_TARGET_IMMUNETarget_ImmuneUnable to target
SOURCE2MOD_TARGET_EMPTY_FILTERTarget_EmptyFilterNo matching clients — an @ pattern that matched nobody
SOURCE2MOD_TARGET_NOT_HUMANTarget_NotHumanCannot target bot
SOURCE2MOD_TARGET_AMBIGUOUSTarget_AmbiguousMore than one client matched

GetTargetErrorPhrase(error) gives the phrase name, to translate; GetTargetErrorText(error) the same in English, for a reply that is not translated.

A command with a target

plugin.cpp
// In Load(): the error phrases and target names live in common.phrases.
g_pSource2ModTranslations->LoadPhraseFile(g_PluginID, "common.phrases", error, maxlen);

REGISTER_CON_COMMAND("mykick", [](const Source2ModCommandContext& ctx, const Source2ModCommandArgs& args, bool post)
{
    if (args.ArgC() < 2)
    {
        REPLY_TO_COMMAND(ctx, "Usage: mykick <target>");
        return;
    }

    const CPlayerSlot caller = ctx.GetPlayerSlot();
    std::vector<CPlayerSlot> targets;
    Source2ModTargetName name;
    const int count = g_pSource2ModTargeting->ProcessTargetString(args[1], caller, SOURCE2MOD_TARGET_NO_BOTS, targets, &name);

    char text[128];
    if (count <= 0)
    {
        g_pSource2ModTranslations->Translate(g_PluginID, g_pSource2ModTargeting->GetTargetErrorPhrase(count), caller, text, sizeof(text));
        g_pSource2ModCommands->ReplyToCommand(ctx, text);
        return;
    }

    for (CPlayerSlot target : targets)
        g_pSource2ModClients->Kick(target, "Kicked by an admin");

    if (name.isPhrase)
        g_pSource2ModTranslations->Translate(g_PluginID, name.text.c_str(), caller, text, sizeof(text));
    else
        snprintf(text, sizeof(text), "%s", name.text.c_str());
    REPLY_TO_COMMAND(ctx, "Kicked %s.", text);
}, "myplugin.admin.kick");

The targets come back in slot order. In SourcePawn they are plain int slots in an array the caller provides (maxTargets is its size).

Naming the targets

The optional name says what was targeted, for the reply or a chat announcement. For an @ pattern it is a phrase name in common.phrases ("all terrorists", "all but yourself", ...) and isPhrase / nameIsPhrase is set; for one player it is their name. A plugin translates the phrase with its own copy of common.phrases — phrases are per plugin, so load the file first.

One player

FindTarget is ProcessTargetString with NO_MULTI: one slot or an error.

plugin.cpp
REGISTER_CON_COMMAND("slay", [](const Source2ModCommandContext& ctx, const Source2ModCommandArgs& args, bool post)
{
    CPlayerSlot target(-1);
    const int result = g_pSource2ModTargeting->FindTarget(args[1], ctx.GetPlayerSlot(), SOURCE2MOD_TARGET_ALIVE, target);
    if (result != 1)
    {
        REPLY_TO_COMMAND(ctx, "%s", g_pSource2ModTargeting->GetTargetErrorText(result));
        return;
    }

    CCSPlayerController* player = CCSPlayerController::FromSlot(target);
    if (CCSPlayerPawn* pawn = player ? player->GetPawn() : nullptr)
        pawn->CommitSuicide(false, true);
}, "myplugin.admin.slay");

Immunity

Unless NO_IMMUNITY is given, every candidate goes through CanTarget(caller, target): the caller's immunity has to be at least the target's. It is not checked for the server console (caller -1), for the caller themselves, or for @me.

A lone target that is immune gives TARGET_IMMUNE. In an @ pattern the immune players are left out; if that leaves nobody, the result is TARGET_EMPTY_FILTER. The ALIVE / DEAD / NO_BOTS / in-game filters work the same way.

Filters of your own

AddTargetFilter adds an @ name. The filter gets the caller and fills the list with whom it means; the core sorts it, drops duplicates and applies the flags and immunity. Returning false means it cannot be used now (it then matches nobody).

plugin.cpp
// Load()
m_AdminsFilter = g_pSource2ModTargeting->AddTargetFilter("@admins", [](CPlayerSlot caller, std::vector<CPlayerSlot>& targets)
{
    for (int i = 0; i < 64; i++)
    {
        if (g_pSource2ModClients->IsInGame(CPlayerSlot(i)) && PERMISSIONS_PLAYER_HAS(CPlayerSlot(i), "myplugin.admin"))
            targets.push_back(CPlayerSlot(i));
    }
    return true;
}, "all admins", false);

// optional: unloading the plugin removes it too
g_pSource2ModTargeting->RemoveTargetFilter(m_AdminsFilter);
  • The name may be given with or without the @. It is refused (id 0) when another filter has it, or it is a built-in one (all, ct, me, aim, ...).
  • The phrase names the targets. With phraseIsTranslation set it is a phrase name in common.phrases and comes back with isPhrase set; without, it is shown as it is.

Pitfalls

  • A part of a name can be ambiguous without NO_MULTI. bo matching both bob and bobby gives TARGET_AMBIGUOUS — a partial name only ever means one player. Players type #<userid> or the full name then.
  • #<name> is case-sensitive, a bare name is not. #Bob needs the exact name; bob matches Bob and wins over Bobby.
  • @me and @aim from the console match nobody. The console has no player.
  • An unknown @ name gives TARGET_NONE, not TARGET_EMPTY_FILTER. The latter means a known @ pattern that matched nobody — also after immunity and the flags removed everyone.
  • GetLatency is in seconds. Multiply by 1000 for the ping a scoreboard shows.
  • GetAuthId is empty before validation. Pass validated = false only for display, never for bans or admin lookups.

Reference

On this page