Skip to content

NPC Compatibility Guide

SupremeSpookmaster edited this page Apr 22, 2025 · 6 revisions

Something you will find if you are running the Portable NPC System on a heavily-modded server is that many old and/or poorly-coded plugins were written with only clients in mind, and will therefore not work against NPCs. This brief guide is meant to outline how to make these plugins compatible with NPCs, so that they work against them just like they would work against a player.

Custom Damage Sources

The most common errors with NPC compatibility stem from custom damage sources being unable to hit entities. This is a very simple fix, which I will show using a generic explosion as an example:

Click here for an example of an incompatible explosion script.
void MyGenericExplosion(float groundZero[3], float radius, float damage, TFTeam targetTeam, int attacker)
{
    for (int i = 1; i <= MaxClients; i++)
    {
        if (!IsValidMulti(i))    //For the sake of this example, we will just assume "IsValidMulti" is a stock which checks if the client is both valid and alive.
            continue;
        if (TF2_GetClientTeam(i) != targetTeam)
            continue;

        float vicLoc[3];
        GetClientAbsOrigin(i, vicLoc);

        if (GetVectorDistance(vicLoc, groundZero) <= radius && HasLineOfSight(groundZero, vicLoc))
            SDKHooks_TakeDamage(i, attacker, attacker, damage, DMG_BLAST);
}

bool HasLineOfSight(float pos1[3], float pos2[3])
{
    Handle trace = TR_TraceRayFilterEx(pos1, pos2, MASK_SHOT, RayType_EndPoint, LOSCheck_IgnorePlayers);
    bool clear = !TR_DidHit(trace);
    delete trace;
    return clear;
}

bool LOSCheck_IgnorePlayers(entity, contentsMask, target)
{	
    return entity == 0 || entity > MaxClients;
}

The above script will work as intended against clients, but it fails to hit entities. This means it cannot hit the engineer's buildings and, as is relevant to this guide, cannot hit NPCs. How do we fix this?

Click here to see an edited version of the above script, which should now be compatible with NPCs.
void MyGenericExplosion(float groundZero[3], float radius, float damage, TFTeam targetTeam, int attacker)
{
    for (int i = 1; i <= 2048; i++)
    {
        if (!IsValidEntity(i))
            continue;

        if (i <= MaxClients && !IsValidMulti(i)) //Again, for the sake of this example, we will just assume "IsValidMulti" is a stock which checks if the client is both valid and alive.
            continue;

        if (view_as<int>(targetTeam) != GetEntProp(i, Prop_Send, "m_iTeamNum"))
            continue;

        float vicLoc[3];
        GetEntPropVector(i, Prop_Data, "m_vecAbsOrigin", vicLoc);

        if (GetVectorDistance(vicLoc, groundZero) <= radius && HasLineOfSight(groundZero, vicLoc))
            SDKHooks_TakeDamage(i, attacker, attacker, damage, DMG_BLAST, _, _, _, false);
}

bool HasLineOfSight(float pos1[3], float pos2[3])
{
    Handle trace = TR_TraceRayFilterEx(pos1, pos2, MASK_SHOT, RayType_EndPoint, LOSCheck_IgnoreValidTargets);
    bool clear = !TR_DidHit(trace);
    delete trace;
    return clear;
}

bool LOSCheck_IgnoreValidTargets(entity, contentsMask, target)
{	
    return !EntityIsValidTarget(target);
}

bool EntityIsValidTarget(int target)
{
    if (entity == 0 || !IsValidEntity(target))
        return false;

    char classname[255];
    GetEntityClassname(target, classname, sizeof(classname));

    //There is likely a cleaner way of doing this, but it is not relevant to our example:
    return (StrContains(classname, "npc") != -1 || StrContains(classname, "base_boss") != -1 || StrContains(classname, "physics") != -1 ||
    StrContains(classname, "teleporter") != -1 || StrContains(classname, "sentrygun") != -1 || StrContains(classname, "dispenser") != -1);
}

With the provided edits, our generic explosion script should now be compatible with NPCs. As a nice bonus, it is also compatible with the engineer's buildings, as well as physics props! But what exactly did we change?

  1. We replaced all of our client-specific checks (EX: IsValidMulti, GetClientAbsOrigin) with ones which work for all entities (EX: IsValidEntity, GetEntPropVector).
  2. We expanded the explosion's for loop to cycle through all entities and not just players.
  3. We check each victim's team by grabbing m_iTeamNum instead of using TF2_GetClientTeam.
  4. We expanded our line-of-sight check to allow all entities which we want to be able to be hit, namely NPCs and buildings.
  5. We set the bypassHooks parameter of SDKHooks_TakeDamage to false. If you forget this, your custom damage source will never be able to damage NPCs, even if everything else is perfect. This is a tiny, easily-forgotten detail but it can ruin everything, so don't forget it!

This approach can be taken with all custom damage sources, not just explosions, as the steps above apply to everything.

Custom On-Hit Effects

Certain plugins may provide a special effect on-hit. This is an extremely simple solution, which I will again demonstrate using an example.

Click here to see an example of an incompatible on-hit effect which freezes the target.
bool stunned[MAXPLAYERS+1] = {false, ...};

public Action OnTakeDamageAlive(int victim, int &attacker, int &inflictor, float &damage, int &damagetype, int &weapon, float damageForce[3], float damagePosition[3], int &damagecustom)
{
    if (!IsValidEntity(weapon))
        return Plugin_Continue;

    float freezeTime = TF2CustAttr_GetFloat(weapon, "freeze on hit", 0.0);
    if (!stunned[victim] && freezeTime > 0.0)
    {
        stunned[victim] = true;
        SetEntityMoveType(victim, MOVETYPE_NONE);
        CreateTimer(freezeTime, Timer_Unfreeze, GetClientUserId(victim), TIMER_FLAG_NO_MAPCHANGE);
    }

    return Plugin_Continue;
}

public Action Timer_Unfreeze(Handle timer, int id)
{
    int client = GetClientOfUserId(id);
    if (!IsValidMulti(client))
        return Plugin_Continue;

    stunned[client] = false;
    SetEntityMoveType(client, MOVETYPE_WALK);
}

Once again, the above script will work against clients, but will fail against NPCs. The solution, this time, is slightly different.

Click here to see an edited version of the above script, which should now be compatible with NPCs.
bool stunned[2049] = {false, ...};

public Action OnTakeDamageAlive(int victim, int &attacker, int &inflictor, float &damage, int &damagetype, int &weapon, float damageForce[3], float damagePosition[3], int &damagecustom)
{
    AttemptStun(weapon, victim);
    return Plugin_Continue;
}

public Action PNPC_OnPNPCTakeDamage(CFNPC npc, float &damage, int weapon, int inflictor, int attacker, int &damagetype, int &damagecustom)
{
    AttemptStun(weapon, victim);
    return Plugin_Continue;
}

void AttemptStun(int weapon, int victim)
{
    if (!IsValidEntity(weapon))
        return;

    float freezeTime = TF2CustAttr_GetFloat(weapon, "freeze on hit", 0.0);
    if (!stunned[victim] && freezeTime > 0.0)
    {
        stunned[victim] = true;
        SetEntityMoveType(victim, MOVETYPE_NONE);
        CreateTimer(freezeTime, Timer_Unfreeze, EntIndexToEntRef(victim), TIMER_FLAG_NO_MAPCHANGE);
    }
}

public Action Timer_Unfreeze(Handle timer, int ref)
{
    int victim = EntRefToEntIndex(ref);
    if (!IsValidEntity(victim))
        return Plugin_Continue;

    if (victim > 0 && victim <= MaxClients && !IsValidMulti(victim))
        return Plugin_Continue;

    stunned[victim] = false;
    SetEntityMoveType(victim, MOVETYPE_WALK);
}

With our edits, the on-hit effect should now work against NPCs. Here are the specific steps needed to replicate this for your own on-hit effects:

  1. We replaced all of our client-specific checks (EX: IsValidMulti, GetClientUserId) with ones which work for all entities (EX: IsValidEntity, EntIndexToEntRef).
  2. We trigger our custom on-hit logic in both the client's OnTakeDamageAlive hook, as well as the NPC's. If you are using the Portable NPC System, you do not need to hook the NPC, and can just use PNPC_OnPNPCTakeDamage, otherwise you will need to make your own hook using SDKHook_OnTakeDamage.

Just like with custom damage sources, this approach can be taken with all custom on-hit effects, as the two steps apply to everything.

Clone this wiki locally