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.
#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++ | SourcePawn | Example |
|---|---|---|
SOURCE2MOD_AUTHID_ENGINE | AuthIdForm_Engine | as the engine shows it (Steam2) |
SOURCE2MOD_AUTHID_STEAM2 | AuthIdForm_Steam2 | STEAM_1:0:12345 |
SOURCE2MOD_AUTHID_STEAM3 | AuthIdForm_Steam3 | [U:1:24690] |
SOURCE2MOD_AUTHID_STEAMID64 | AuthIdForm_SteamID64 | 76561197960290418 |
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());
}
});public void Cmd_WhoIs(Source2ModCommandContext ctx, Source2ModCommandArgs args, bool post)
{
for (int i = 0; i < 64; i++)
{
CPlayerSlot slot = CPlayerSlot(i);
if (!Source2ModClients.IsConnected(slot) || Source2ModClients.IsFakeClient(slot))
continue;
char steam2[32], lang[16];
Source2ModClients.GetAuthId(slot, AuthIdForm_Steam2, steam2, sizeof(steam2), false);
Source2ModClients.GetClientConVar(slot, "cl_language", lang, sizeof(lang));
Source2ModCommands.ReplyToCommand(ctx, "#%d %s%s ping %.0f ms, %.0f s, %s",
Source2ModClients.GetUserId(slot), steam2,
Source2ModClients.IsAuthorized(slot) ? "" : " (not validated)",
Source2ModClients.GetLatency(slot) * 1000.0,
Source2ModClients.GetConnectionTime(slot), lang);
}
}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.
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());
}
}public void Source2Mod_OnClientAuthorized(CPlayerSlot slot, const char[] steamId)
{
char steam2[32];
Source2ModClients.GetAuthId(slot, AuthIdForm_Steam2, steam2, sizeof(steam2));
if (IsBanned(steam2))
Source2ModClients.Kick(slot, "banned (%s)", steam2);
}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. |
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.
| Pattern | Means |
|---|---|
@all, @t, @ct, @spec | Everybody, a team |
@alive, @dead, @bots, @humans | By state |
@me, @!me | The caller; everybody but the caller |
@aim | The 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 SteamID | Any form, with or without #: STEAM_1:0:1, #[U:1:2], 7656119... |
#<name> | A player whose name is exactly this |
| anything else | Part 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_ALIVE | Target_Alive | Only alive players |
SOURCE2MOD_TARGET_DEAD | Target_Dead | Only dead players |
SOURCE2MOD_TARGET_CONNECTED | Target_Connected | Also players not fully in game |
SOURCE2MOD_TARGET_NO_IMMUNITY | Target_NoImmunity | Skip the immunity check |
SOURCE2MOD_TARGET_NO_MULTI | Target_NoMulti | One player only; @ patterns are refused |
SOURCE2MOD_TARGET_NO_BOTS | Target_NoBots | No bots |
Results
A result above 0 is the number of players. Otherwise it is an error
(SourceMod's COMMAND_TARGET_*):
| C++ | SourcePawn | Phrase in common.phrases |
|---|---|---|
SOURCE2MOD_TARGET_NONE (0) | Target_None | No matching client |
SOURCE2MOD_TARGET_NOT_ALIVE | Target_NotAlive | Target must be alive |
SOURCE2MOD_TARGET_NOT_DEAD | Target_NotDead | Target must be dead |
SOURCE2MOD_TARGET_NOT_IN_GAME | Target_NotInGame | Target is not in game |
SOURCE2MOD_TARGET_IMMUNE | Target_Immune | Unable to target |
SOURCE2MOD_TARGET_EMPTY_FILTER | Target_EmptyFilter | No matching clients — an @ pattern that matched nobody |
SOURCE2MOD_TARGET_NOT_HUMAN | Target_NotHuman | Cannot target bot |
SOURCE2MOD_TARGET_AMBIGUOUS | Target_Ambiguous | More 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
// 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");public void OnPluginStart()
{
// The error phrases and target names live in common.phrases.
Source2ModTranslations.LoadPhraseFile("common.phrases");
Source2ModCommands.RegisterConCommand("mykick", Cmd_Kick, "myplugin.admin.kick");
}
public void Cmd_Kick(Source2ModCommandContext ctx, Source2ModCommandArgs args, bool post)
{
if (args.ArgC() < 2)
{
Source2ModCommands.ReplyToCommand(ctx, "Usage: mykick <target>");
return;
}
char pattern[64], name[64];
args.Arg(1, pattern, sizeof(pattern));
CPlayerSlot caller = ctx.GetPlayerSlot();
int targets[64];
bool nameIsPhrase;
int count = Source2ModTargeting.ProcessTargetString(pattern, caller, Target_NoBots,
targets, sizeof(targets), name, sizeof(name), nameIsPhrase);
if (count <= 0)
{
char error[64];
Source2ModTargeting.GetTargetErrorPhrase(count, error, sizeof(error));
Source2ModCommands.ReplyToCommand(ctx, "%T", error, caller.Get());
return;
}
for (int i = 0; i < count; i++)
Source2ModClients.Kick(CPlayerSlot(targets[i]), "Kicked by an admin");
if (nameIsPhrase)
Source2ModCommands.ReplyToCommand(ctx, "Kicked %T.", name, caller.Get());
else
Source2ModCommands.ReplyToCommand(ctx, "Kicked %s.", name);
}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.
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");public void Cmd_Slay(Source2ModCommandContext ctx, Source2ModCommandArgs args, bool post)
{
char pattern[64], error[128];
args.Arg(1, pattern, sizeof(pattern));
CPlayerSlot target;
int result = Source2ModTargeting.FindTarget(pattern, ctx.GetPlayerSlot(), Target_Alive, target);
if (result != 1)
{
Source2ModTargeting.GetTargetErrorText(result, error, sizeof(error));
Source2ModCommands.ReplyToCommand(ctx, "%s", error);
return;
}
CCSPlayerPawn pawn = CCSPlayerController.FromSlot(target).GetPawn();
if (pawn != NULL_ENTITY)
pawn.CommitSuicide(false, true);
}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).
// 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);int g_filter;
public void OnPluginStart()
{
g_filter = Source2ModTargeting.AddTargetFilter("@admins", Filter_Admins, "all admins");
}
public bool Filter_Admins(CPlayerSlot caller, ArrayList targets)
{
for (int i = 0; i < 64; i++)
{
if (Source2ModClients.IsInGame(CPlayerSlot(i)) && Source2ModPermissions.PlayerHasPermission(CPlayerSlot(i), "myplugin.admin"))
targets.Push(i);
}
return true;
}
public void OnPluginEnd()
{
Source2ModTargeting.RemoveTargetFilter(g_filter); // optional: unloading removes it too
}- 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
phraseIsTranslationset it is a phrase name in common.phrases and comes back withisPhraseset; without, it is shown as it is.
Pitfalls
- A part of a name can be ambiguous without
NO_MULTI.bomatching bothbobandbobbygivesTARGET_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.#Bobneeds the exact name;bobmatchesBoband wins overBobby.@meand@aimfrom the console match nobody. The console has no player.- An unknown
@name givesTARGET_NONE, notTARGET_EMPTY_FILTER. The latter means a known@pattern that matched nobody — also after immunity and the flags removed everyone. GetLatencyis in seconds. Multiply by 1000 for the ping a scoreboard shows.GetAuthIdis empty before validation. Passvalidated = falseonly for display, never for bans or admin lookups.
Reference
ISource2ModClients— every call with its parameters.ISource2ModTargeting— every call with its parameters.
Permissions
Admins, groups and permissions — permission-gated commands, permissions.json, granting at runtime from your own database, and which SteamID a player is checked under.
Translations
Phrases per plugin in each player's language — phrase files, phrases pushed at runtime from JSON or code, formatting them for a player, and how a player's language is chosen.