VScript Examples
This page contains examples of vscripts specifically for Team Fortress 2.
Generic examples that work on all VScript games can be found on the SDK 2013 page.
Iterating Through Player's Cosmetics
Cosmetics are called "wearables" internally.

for (local wearable = player.FirstMoveChild(); wearable != null; wearable = wearable.NextMovePeer())
{
if (wearable.GetClassname() != "tf_wearable")
continue
printl(wearable)
}
Swapping the !activator's Team
Whenever a script's code gets executed by inputs (e.g.,RunScriptCode
/CallScriptFunction
on an entity with an attached entity script), the activator
and caller
variables will be set to the handles of the input's !activator and !caller respectively, allowing you to access them in code.
We can use that to easily do something to players that walk in a specific trigger, for example.
As for swapping the !activator's team, we need to do both change their team based on which team they are, and then also change the their cosmetic items' team to the their's new team.
First we need to figure on which team the !activator is. For that, we can use the GetTeam()
method on the !activator (activator.GetTeam()
) to get the number index of their team.
You can either use an if function to compare the returned value with the desired team index, or store it in a variable to use it later. The latter in this case may be better to reduce the length of the script.
For reference: 0 is Unassigned (no team yet), 1 is Spectator, 2 is Red, 3 is Blu.
To change the !activator's team, we should use the ForceChangeTeam(Int : Team index, Bool : Kill player if game is in Highlander mode + remove their dominations)
method on them via activator.ForceChangeTeam(...)
.
To change their cosmetic items' color, we need to iterate over every tf_wearable entity, and then change its team via SetTeam(Int : Team index)
if they are worn by the !activator.
- To iterate, we need to specify a null variable (e.g.
local cosmetic = null
), and then pass it into the following while loop:
local cosmetic = null // Assign the "cosmetic" variable to null, will be assigned new values when going over cosmetic items
while (cosmetic = Entities.FindByClassname(cosmetic, "tf_wearable"))
{
// Do things to individual tf_wearables entities here, the "cosmetic" variable is set to the current tf_wearable entity we are iterating through
}
- To check if the cosmetic item belongs to the !activator, we can simply compare the value returned by the
GetOwner()
method when we use it on the tf_wearable (e.g.cosmetic.GetOwner()
) against the !activator. Example code:
if (cosmetic.GetOwner() == activator)
{
// Do things if the cosmetic's wearer is the !activator
}
- To change the cosmetic's team, we should use the
SetTeam(Int : Team index)
method on the cosmetic item entity.
Full code with comments:
function ActivatorSwapTeam() // Call this in map via RunScriptCode > ActivatorSwapTeam() or CallScriptFunction > ActivatorSwapTeam on a logic_script that has an Entity Script with this function
{
// The following snippet checks if an !activator has been specified and if they are a player
// If either question's answer is no, then don't execute the rest of the function
if (activator == null || activator.IsPlayer() == false)
{
return
}
// The following snippet compares the !activator's team number (Ranging from 0 to 3) to the ones used by Unassigned (0) and Spectator (1)
// If they match with either Unassigned or Spectator, we don't execute the rest of the function
// Used to ignore any potentional spectator !activators
if (activator.GetTeam() == 0 || activator.GetTeam() == 1)
{
return
}
// The following snippet specifies a local newTeam variable, and then we set it to a team number based off the !activator's current team number
local newTeam = 0
if (activator.GetTeam() == 2) // Checks if the !activator's team number is 2 (Red), and sets the newTeam variable to 3 (Blu)
{
newTeam = 3
} else { // If the !activator's team number is not 2 (Red), sets the newTeam variable to 2 (Red) instead
newTeam = 2
}
// The following snippet calls the ForceChangeTeam method on the !activator
// First parameter: Team number to switch to
// Second parameter: If false, the game will reset the player's dominations and nemesises, and kill them if mp_highlander is on
activator.ForceChangeTeam(newTeam, true)
local cosmetic = null // Assign the "cosmetic" variable to null, will be assigned new values when going over cosmetic items
// The following snippet will go over every cosmetic item currently present, and will change its colours to the appropriate team if they are the !activator's
while (cosmetic = Entities.FindByClassname(cosmetic, "tf_wearable")) // Goes over every cosmetic item, executing code below
{
if (cosmetic.GetOwner() == activator) // Checks if the currently iterated cosmetic item's wearer is the !activator
{
cosmetic.SetTeam(newTeam) // Sets the team of the cosmetic item to the new team number that we stored in newTeam
}
}
}
Adding attributes to player on spawn
Intuitively, applying attributes to the player on the player_spawn
event should work. Unfortunately this doesn't work in practice, as the game clears all custom attributes immediately after this event is fired. As a workaround, the attributes can be postponed to be added at the end of the frame, as shown below. The team check is requied because player_spawn
runs once when a player is put on the unassigned team.

::PostPlayerSpawn <- function()
{
// "self" is the player entity here
self.AddCustomAttribute("no jump", 1, -1)
}
function OnGameEvent_player_spawn(params)
{
local player = GetPlayerFromUserID(params.userid)
if (player != null && params.team != 0)
{
EntFireByHandle(player, "CallScriptFunction", "PostPlayerSpawn", 0, null, null)
}
}
Setting up a Boss Health Bar
The boss bar that appears while any bosses are active (such as MONOCULUS!) is handled by the monster_resource
entity, which conveniently exists in the map normally.
You can do Entities.FindByClassname(null, "monster_resource")
to get the monster_resource
entity most of the time, but it'd be convenient to store it into a variable instead.
In order to modify the health bar's percentage, it'd be useful to first know about NetProps:
- NetProps are network properties of an entity, which are server-side only.
- They can be accessed and changed by the NetProps class methods.
The monster_resource
entity has a m_iBossHealthPercentageByte NetProp, which determines the percentage state of the health bar based on a byte value - its value must be between 0 and 255, where 0 is 0%, and 255 is 100%
The following code will add a health bar with 25% health after executed.
local healthBar = Entities.FindByClassname(null, "monster_resource") // Get the health bar entity.
if (healthBar) { // Check if the health bar entity exists, just in case to prevent errors
// The following line will update the health bar's percentage to 25% by changing its NetProp.
// Do note that because it is a byte (0 - 255), we need to multiply our percentage by 255.
NetProps.SetPropInt(healthBar, "m_iBossHealthPercentageByte", 0.25 * 255)
}
Giving weapons, cosmetics and taunts
All weapons, cosmetics, taunts, economy items etc are stored within items_game.txt
. This file is found within the game's directory /tf/scripts/items/
.
Giving a weapon
See this page to find corresponding classname and itemIDs for each weapon.

m_iObjectType
and m_iObjectMode
netprops to 0 before equipping. You also need to set the m_aBuildableObjectTypes
netprop array to allow objects to be built.::MAX_WEAPONS <- 8
::GivePlayerWeapon <- function(player, className, itemID)
{
local weapon = Entities.CreateByClassname(className)
NetProps.SetPropInt(weapon, "m_AttributeManager.m_Item.m_iItemDefinitionIndex", itemID)
NetProps.SetPropBool(weapon, "m_AttributeManager.m_Item.m_bInitialized", true)
NetProps.SetPropBool(weapon, "m_bValidatedAttachedEntity", true)
weapon.SetTeam(player.GetTeam())
weapon.DispatchSpawn()
// remove existing weapon in same slot
for (local i = 0; i < MAX_WEAPONS; i++)
{
local heldWeapon = NetProps.GetPropEntityArray(player, "m_hMyWeapons", i)
if (heldWeapon == null)
continue
if (heldWeapon.GetSlot() != weapon.GetSlot())
continue
heldWeapon.Destroy()
NetProps.SetPropEntityArray(player, "m_hMyWeapons", null, i)
break
}
player.Weapon_Equip(weapon)
player.Weapon_Switch(weapon)
return weapon
}
GivePlayerWeapon(GetListenServerHost(), "tf_weapon_smg", 16)
Giving a cosmetic
Spawns a tf_wearable entity made by the B.A.S.E. Jumper item. This tf_wearable allows for any model to appear on the player's ragdoll upon death. (Player ragdolls are client-side and cannot be accessed by VScript, so there is no other way to parent anything to the ragdoll unless you use this kind of tf_wearable)
Optionally, this method can be used to update the player's bodygroups, which cannot be altered with SetBodygroup()
. A fix is to give an econ item that removes a bodygroup, set the model to "models/empty.mdl", then send the Game Event post_inventory_application
to the player to refresh the bodygroups of the econ items.


GetPlayerUserID()
from an earlier example here.::GivePlayerCosmetic <- function(player, item_id, model_path = null)
{
local weapon = Entities.CreateByClassname("tf_weapon_parachute")
NetProps.SetPropInt(weapon, "m_AttributeManager.m_Item.m_iItemDefinitionIndex", 1101)
NetProps.SetPropBool(weapon, "m_AttributeManager.m_Item.m_bInitialized", true)
weapon.SetTeam(player.GetTeam())
weapon.DispatchSpawn()
player.Weapon_Equip(weapon)
local wearable = NetProps.GetPropEntity(weapon, "m_hExtraWearable")
weapon.Kill()
NetProps.SetPropInt(wearable, "m_AttributeManager.m_Item.m_iItemDefinitionIndex", item_id)
NetProps.SetPropBool(wearable, "m_AttributeManager.m_Item.m_bInitialized", true)
NetProps.SetPropBool(wearable, "m_bValidatedAttachedEntity", true)
wearable.DispatchSpawn()
// (optional) Set the model to something new. (Obeys econ's ragdoll physics when ragdolling as well)
if (model_path)
wearable.SetModelSimple(model_path)
// (optional) recalculates bodygroups on the player
SendGlobalGameEvent("post_inventory_application", { userid = GetPlayerUserID(player) })
// (optional) if one wants to delete the item entity, collect them within the player's scope, then send Kill() to the entities within the scope.
player.ValidateScriptScope()
local player_scope = player.GetScriptScope()
if (!("wearables" in player_scope))
player_scope.wearables <- []
player_scope.wearables.append(wearable)
return wearable
}
Giving a taunt
There is no known way to give taunts directly into the inventory, but all taunts can be forced using a workaround as shown below.
For a list of taunts, search their ID in items_games.txt
. The Alliedmodders page also has an incomplete list.
Example usage (conga): ForceTaunt(GetListenServerHost(), 1118)
function ForceTaunt(player, taunt_id)
{
local weapon = Entities.CreateByClassname("tf_weapon_bat")
local active_weapon = player.GetActiveWeapon()
player.StopTaunt(true) // both are needed to fully clear the taunt
player.RemoveCond(7)
weapon.DispatchSpawn()
NetProps.SetPropInt(weapon, "m_AttributeManager.m_Item.m_iItemDefinitionIndex", taunt_id)
NetProps.SetPropBool(weapon, "m_AttributeManager.m_Item.m_bInitialized", true)
NetProps.SetPropBool(weapon, "m_bForcePurgeFixedupStrings", true)
NetProps.SetPropEntity(player, "m_hActiveWeapon", weapon)
NetProps.SetPropInt(player, "m_iFOV", 0) // fix sniper rifles
player.HandleTauntCommand(0)
NetProps.SetPropEntity(player, "m_hActiveWeapon", active_weapon)
weapon.Kill()
}
Creating Mannpower powerups
Creating any type of Mannpower powerup other than Strength is usually not possible manually. However, players are able to drop powerups, which can therefore be manipulated to drop any kind of powerup. The following example abstracts this to allow creating a powerup with a given position, angles, velocity, team and type. The powerup can also be set not to "re-position" (blink and disappear) after time has passed, unlike usual dropped powerups.
Example usage:
CreateRune(Vector(0, 0, -100), QAngle(0, 0, 0), Vector(-500, 0, 0), Constants.ETFTeam.TEAM_ANY, ERuneTypes.RUNE_VAMPIRE, false)

item_powerup_rune
to zitem_powerup_rune
for speed.enum ERuneTypes
{
RUNE_STRENGTH,
RUNE_HASTE,
RUNE_REGEN,
RUNE_RESIST,
RUNE_VAMPIRE,
RUNE_REFLECT,
RUNE_PRECISION,
RUNE_AGILITY,
RUNE_KNOCKOUT,
RUNE_KING,
RUNE_PLAGUE,
RUNE_SUPERNOVA,
}
::RuneTypeToCond <-
[
Constants.ETFCond.TF_COND_RUNE_STRENGTH,
Constants.ETFCond.TF_COND_RUNE_HASTE,
Constants.ETFCond.TF_COND_RUNE_REGEN,
Constants.ETFCond.TF_COND_RUNE_RESIST,
Constants.ETFCond.TF_COND_RUNE_VAMPIRE,
Constants.ETFCond.TF_COND_RUNE_REFLECT,
Constants.ETFCond.TF_COND_RUNE_PRECISION,
Constants.ETFCond.TF_COND_RUNE_AGILITY,
Constants.ETFCond.TF_COND_RUNE_KNOCKOUT,
Constants.ETFCond.TF_COND_RUNE_KING,
Constants.ETFCond.TF_COND_RUNE_PLAGUE,
Constants.ETFCond.TF_COND_RUNE_SUPERNOVA,
]
::MaxPlayers <- MaxClients().tointeger()
::CreateRune <- function(origin, angles, velocity, team, type, reposition)
{
// select random player to create a rune from
// prioritize players with no rune, as stripping the rune temporarily can have side effects
local player, fallback
for (local i = 1; i <= MaxPlayers; i++)
{
player = PlayerInstanceFromIndex(i)
if (player)
{
if (player.IsCarryingRune())
{
fallback = player
player = null
continue
}
break
}
}
if (!player)
{
if (!fallback)
return null
player = fallback
}
// to detect the rune that was spawned, every existing rune must be hidden
for (local rune; rune = Entities.FindByClassname(rune, "item_powerup_rune");)
rune.KeyValueFromString("classname", "zitem_powerup_rune")
local cond = RuneTypeToCond[type]
// if player already has a rune, temporarily strip it
local player_cond, player_cond_duration
if (player.IsCarryingRune())
{
foreach (cond in RuneTypeToCond)
{
player_cond_duration = player.GetCondDuration(cond)
if (player_cond_duration != 0.0)
{
player.RemoveCond(cond)
player_cond = cond
break
}
}
}
local cond_prop = "m_Shared." + (cond >= 96 ? "m_nPlayerCondEx3" : "m_nPlayerCondEx2")
local cond_bits = NetProps.GetPropInt(player, cond_prop)
NetProps.SetPropInt(player, cond_prop, cond_bits | (1 << (cond % 32)))
player.DropRune(false, team)
NetProps.SetPropInt(player, cond_prop, cond_bits)
// give original rune back
if (player_cond)
player.AddCondEx(player_cond, player_cond_duration, null)
local rune = Entities.FindByClassname(null, "item_powerup_rune")
if (!rune)
return null
rune.Teleport(true, origin, true, angles, true, velocity)
if (!reposition)
{
// prevents rune from blinking after 30 or 60 seconds
// and teleporting to a spawnpoint if one exists
rune.AddEFlags(Constants.FEntityEFlags.EFL_NO_THINK_FUNCTION)
rune.AddSolidFlags(Constants.FSolid.FSOLID_TRIGGER)
}
return rune
}
Detecting melee smacks / creating projectiles with damage
This example demonstrates how to reliably detect when a melee has fired, by abusing a quirk in one of the melee's internal variables. When a Heavy attacks with their fists, they will emit a fireball like from the Dragon's Fury. Therefore this example also demonstrates how to create projectiles that deal damage which can be problematic by usual means.
if (!("FireballMaker" in getroottable()) || !FireballMaker.IsValid())
{
// spawn a fake dragon's fury to emit the fireballs
FireballMaker <- Entities.CreateByClassname("tf_weapon_rocketlauncher_fireball")
NetProps.SetPropInt(FireballMaker, "m_AttributeManager.m_Item.m_iItemDefinitionIndex", 1178)
NetProps.SetPropBool(FireballMaker, "m_AttributeManager.m_Item.m_bInitialized", true)
FireballMaker.DispatchSpawn()
FireballMaker.SetClip1(-1)
}
function CheckMeleeSmack()
{
local owner = self.GetOwner()
// when melee smacks, m_iNextMeleeCrit is 0
if (NetProps.GetPropInt(owner, "m_Shared.m_iNextMeleeCrit") == 0)
{
// when switching away from melee, m_iNextMeleeCrit will also be 0 so check for that case
if (owner.GetActiveWeapon() == self)
{
// preserve old charge meter and ammo count
local charge = NetProps.GetPropFloat(owner, "m_Shared.m_flItemChargeMeter")
local ammo = NetProps.GetPropIntArray(owner, "m_iAmmo", 1)
// set up stuff needed to ensure the weapon always fires
NetProps.SetPropIntArray(owner, "m_iAmmo", 99, 1)
NetProps.SetPropFloat(owner, "m_Shared.m_flItemChargeMeter", 100.0)
NetProps.SetPropBool(owner, "m_bLagCompensation", false)
NetProps.SetPropFloat(FireballMaker, "m_flNextPrimaryAttack", 0)
NetProps.SetPropEntity(FireballMaker, "m_hOwner", owner)
FireballMaker.PrimaryAttack()
// revert changes
NetProps.SetPropBool(owner, "m_bLagCompensation", true)
NetProps.SetPropIntArray(owner, "m_iAmmo", ammo, 1)
NetProps.SetPropFloat(owner, "m_Shared.m_flItemChargeMeter", charge)
}
// continue smack detection
NetProps.SetPropInt(owner, "m_Shared.m_iNextMeleeCrit", -2)
}
return -1
}
// event listener (see Listening for Events example)
CollectEventsInScope
({
OnGameEvent_player_spawn = function(params)
{
local player = GetPlayerFromUserID(params.userid)
if (!player)
return
local player_class = params["class"]
if (player_class != Constants.ETFClass.TF_CLASS_HEAVYWEAPONS)
return
for (local i = 0; i < 8; i++)
{
local weapon = NetProps.GetPropEntityArray(player, "m_hMyWeapons", i)
if (weapon == null || !weapon.IsMeleeWeapon())
continue
// this kicks off melee smack detection logic
NetProps.SetPropInt(player, "m_Shared.m_iNextMeleeCrit", -2)
AddThinkToEnt(weapon, "CheckMeleeSmack")
break
}
}
})
Detecting weapon firing
Similar to above example, on all weapons except melee there is another variable that can be tracked. This example pushes the player back when they fire their weapon.
function CheckWeaponFire()
{
local fire_time = NetProps.GetPropFloat(self, "m_flLastFireTime")
if (fire_time > last_fire_time)
{
printf("%f %s : Fired\n", Time(), self.GetClassname())
local owner = self.GetOwner()
if (owner)
{
owner.SetAbsVelocity(owner.GetAbsVelocity() - owner.EyeAngles().Forward() * 800.0)
}
last_fire_time = fire_time
}
return -1
}
// event listener (see Listening for Events example)
CollectEventsInScope
({
OnGameEvent_player_spawn = function(params)
{
local player = GetPlayerFromUserID(params.userid)
if (!player)
return
for (local i = 0; i < 8; i++)
{
local weapon = NetProps.GetPropEntityArray(player, "m_hMyWeapons", i)
if (weapon == null || weapon.IsMeleeWeapon())
continue
weapon.ValidateScriptScope()
weapon.GetScriptScope().last_fire_time <- 0.0
weapon.GetScriptScope().CheckWeaponFire <- CheckWeaponFire
AddThinkToEnt(weapon, "CheckWeaponFire")
}
}
})
Disabling Medic health regen
This effectively cancels out the passive health regeneration from a medic.
function OnGameEvent_player_healed(params)
{
if (params.patient == params.healer)
{
local player = GetPlayerFromUserID(params.patient)
if (player
&& player.GetPlayerClass() == Constants.ETFClass.TF_CLASS_MEDIC
&& NetProps.GetPropInt(player, "m_bitsDamageType") == 32)
{
player.SetHealth(player.GetHealth() - params.amount)
}
}
}
Special death effects on triggers
Weapons that have special death effects (such as disintegration from Phlogistinator, or the ice statue from Spy-cicle) can be applied to triggers via two methods:
Using the TakeDamageCustom
function
This method will work for most listed custom damage types, e.g. decapitation or Cow Mangler's dissolve effect. Otherwise, the other method must be used.
Add the following output to a trigger_hurt
. Replace 9999 with the desired damage and TF_DMG_CUSTOM
part with the desired effect.
OnStartTouch | !activator | RunScriptCode | self.TakeDamageCustom(activator, activator, null, Vector(), Vector(), 9999, 0, Constants.ETFDmgCustom.TF_DMG_CUSTOM_PLASMA)
Using a dummy weapon
Some death effects are not available as a custom damage type, but they can be applied by using a spoofed weapon as the damage owner.
The example below shows how to create a trigger that kills players with a disintegration effect when touched. This script can be assigned to any trigger entity. Note that if the trigger is not a trigger_hurt
, Engineer buildings will be possible to place inside. This can be workarounded by placing a func_nobuild in it's place as well.
// Ensure only one of this entity is ever spawned
if (!("disintegrate_proxy_weapon" in getroottable()))
{
::disintegrate_proxy_weapon <- null
::disintegrate_immune_conds <-
[
Constants.ETFCond.TF_COND_INVULNERABLE,
Constants.ETFCond.TF_COND_PHASE,
Constants.ETFCond.TF_COND_INVULNERABLE_HIDE_UNLESS_DAMAGED,
Constants.ETFCond.TF_COND_INVULNERABLE_USER_BUFF,
Constants.ETFCond.TF_COND_INVULNERABLE_CARD_EFFECT,
]
}
// Create the fake weapon if one doesn't already exist
if (!disintegrate_proxy_weapon || !disintegrate_proxy_weapon.IsValid())
{
disintegrate_proxy_weapon = Entities.CreateByClassname("tf_weapon_bat")
NetProps.SetPropInt(disintegrate_proxy_weapon, "m_AttributeManager.m_Item.m_iItemDefinitionIndex", 349)
NetProps.SetPropBool(disintegrate_proxy_weapon, "m_AttributeManager.m_Item.m_bInitialized", true)
disintegrate_proxy_weapon.DispatchSpawn()
disintegrate_proxy_weapon.DisableDraw()
// Add the attribute that creates disintegration
disintegrate_proxy_weapon.AddAttribute("ragdolls become ash", 1.0, -1.0)
}
function Precache()
{
// Add an output to deal damage when the trigger is touched
self.ConnectOutput("OnStartTouch", "Disintegrate")
}
function Disintegrate()
{
// Remove conditions that give immunity to damage
foreach (cond in disintegrate_immune_conds)
activator.RemoveCondEx(cond, true)
// Set any owner on the weapon to prevent a crash
NetProps.SetPropEntity(disintegrate_proxy_weapon, "m_hOwner", activator)
// Deal the damage with the weapon
activator.TakeDamageCustom(self, activator, disintegrate_proxy_weapon,
Vector(0,0,0), Vector(0,0,0),
99999.0, 2080, Constants.ETFDmgCustom.TF_DMG_CUSTOM_BURNING)
}
Similar example but creates an ice statue on death instead.
// Ensure only one of this entity is ever spawned
if (!("freeze_proxy_weapon" in getroottable()))
{
::freeze_proxy_weapon <- null
::freeze_immune_conds <-
[
Constants.ETFCond.TF_COND_INVULNERABLE,
Constants.ETFCond.TF_COND_PHASE,
Constants.ETFCond.TF_COND_INVULNERABLE_HIDE_UNLESS_DAMAGED,
Constants.ETFCond.TF_COND_INVULNERABLE_USER_BUFF,
Constants.ETFCond.TF_COND_INVULNERABLE_CARD_EFFECT,
]
}
// Create the fake weapon if one doesn't already exist
if (!freeze_proxy_weapon || !freeze_proxy_weapon.IsValid())
{
freeze_proxy_weapon = Entities.CreateByClassname("tf_weapon_knife")
NetProps.SetPropInt(freeze_proxy_weapon, "m_AttributeManager.m_Item.m_iItemDefinitionIndex", 649)
NetProps.SetPropBool(freeze_proxy_weapon, "m_AttributeManager.m_Item.m_bInitialized", true)
freeze_proxy_weapon.DispatchSpawn()
freeze_proxy_weapon.DisableDraw()
// Add the attribute that creates ice statues
freeze_proxy_weapon.AddAttribute("freeze backstab victim", 1.0, -1.0)
}
function Precache()
{
// Add an output to deal damage when the trigger is touched
self.ConnectOutput("OnStartTouch", "Freeze")
}
function Freeze()
{
// Remove conditions that give immunity to damage
foreach (cond in freeze_immune_conds)
activator.RemoveCondEx(cond, true)
// Set any owner on the weapon to prevent a crash
NetProps.SetPropEntity(freeze_proxy_weapon, "m_hOwner", activator)
// Deal the damage with the weapon
activator.TakeDamageCustom(self, activator, freeze_proxy_weapon,
Vector(0,0,0), Vector(0,0,0),
99999.0, Constants.FDmgType.DMG_CLUB, Constants.ETFDmgCustom.TF_DMG_CUSTOM_BACKSTAB)
// I don't remember why this is needed but it's important
local ragdoll = NetProps.GetPropEntity(activator, "m_hRagdoll")
if (ragdoll)
NetProps.SetPropInt(ragdoll, "m_iDamageCustom", 0)
}
Improving player collisions on physics objects or trains
Team Fortress 2 has a subdued physics mode by default, which makes players poorly interact with physics objects unlike other Source games. This can be fixed by setting the
sv_turbophysics
convar to 0.
Convars.SetValue("sv_turbophysics", 0)
func_tracktrains have similar problems. The game has a special train physics mode for Payload to circumvent this issue, which is normally not enabled in other gamemodes. This can be forcefully enabled (or disabled!) via the following:
ForceEscortPushLogic(2)
Force class change
When doing server-only scripts via mapspawn.nut, calling SetPlayerClass can either plain not work or work buggily. For example, looking at your loadout shows the class you tried to choose, rather than forced class and each time you spawn your class will revert back to what you tried to select. By also updating m_iDesiredPlayerClass, these aforementioned bugs no longer occur.
::ForceChangeClass <- function(player, classIndex) {
player.SetPlayerClass(classIndex)
NetProps.SetPropInt(player, "m_Shared.m_iDesiredPlayerClass", classIndex)
player.ForceRegenerateAndRespawn()
}
ForceChangeClass(GetListenServerHost(), Constants.ETFClass.TF_CLASS_SCOUT)
Change how long it takes to capture a control point
When doing server-only scripts via mapspawn.nut, you may want to increase the amount of time it takes to capture a control point. The following snippet increases the time it takes to capture a control point by 2x.
function OnGameEvent_teamplay_round_start(params) {
// Must set this when the map resets only, otherwise the scaling compounds between rounds
// If this is set elsewhere, it may not work as the m_flCapTime gets reset.
if (params.full_reset) {
for (local ent = null; ent = Entities.FindByClassname(ent, "trigger_capture_area");) {
local capPointName = NetProps.GetPropString(ent, "m_iszCapPointName")
local capTimeInSeconds = NetProps.GetPropFloat(ent, "m_flCapTime")
// Double the time it takes to capture a control point
capTimeInSeconds *= 2
NetProps.SetPropFloat(ent, "m_flCapTime", capTimeInSeconds)
// If we do not fire this event, then cap time total does not propagate to the client
// and the UI will be buggy and show the control point capping quicker then it should
// and then "popping" back after the server updates the client.
ent.AcceptInput("SetControlPoint", capPointName, null, null)
}
}
}
Making a model visible to only to a specific player
In the game, entities are usually networked to every player. It may be useful to show a model to a specific player only, which is what this example allows. Engineer buildings only show to their owner during their "blueprint" phase, and this fact can be abused to make a model only show to a desired player.
local proxy_entity = Entities.CreateByClassname("obj_teleporter") // not using SpawnEntityFromTable as that creates spawning noises
proxy_entity.SetAbsOrigin(Vector(0, -320, -150))
proxy_entity.DispatchSpawn()
proxy_entity.SetModel("models/player/heavy.mdl")
proxy_entity.AddEFlags(Constants.FEntityEFlags.EFL_NO_THINK_FUNCTION) // prevents the entity from disappearing
proxy_entity.SetSolid(Constants.ESolidType.SOLID_NONE)
NetProps.SetPropBool(proxy_entity, "m_bPlacing", true)
NetProps.SetPropInt(proxy_entity, "m_fObjectFlags", 2) // sets "attachment" flag, prevents entity being snapped to player feet
// m_hBuilder is the player who the entity will be networked to only
NetProps.SetPropEntity(proxy_entity, "m_hBuilder", GetListenServerHost())
Enabling the Spell HUD and Effects
By setting the m_bIsUsingSpells
netprop to true, you can Enable the spell HUD, the sound effects for rolling spells, and set the spellbook magazine to the default action slot item. Note that spells do work when this netprop is set to false, however the HUD elements/sound effects will not appear, and players will need to manually equip the spellbook if they have not already.
::SetSpellHUD <- function(value)
{
local gamerules = Entities.FindByClassname(null, "tf_gamerules")
NetProps.SetPropBool(gamerules, "m_bIsUsingSpells", value)
}
SetSpellHUD(true)
Changing Player Teams in MvM
When attempting to change to change the player to the blue team in MvM using ForceChangeTeam
or SetTeam
, they will be immediately switched back to RED. To circumvent this, you can temporarily change the tf_gamerules NetProp m_bPlayingMannVsMachine
to false, switch the player team, then set it back to true immediately after. Note that you will need to manually set the robot models for players if you intend to make playable robots.

::ChangePlayerTeamMvM <- function(player, teamnum)
{
local gamerules = Entities.FindByClassname(null, "tf_gamerules")
NetProps.SetPropBool(gamerules, "m_bPlayingMannVsMachine", false)
player.ForceChangeTeam(teamnum, false)
NetProps.SetPropBool(gamerules, "m_bPlayingMannVsMachine", true)
}
ChangePlayerTeamMvM(GetListenServerHost(), Constants.ETFTeam.TF_TEAM_PVE_INVADERS) //Constants.ETFTeam.TF_TEAM_BLUE is also perfectly valid
Changing MvM Mission Names on the Scoreboard
You can change the displayed mission name on the scoreboard by changing the m_iszMvMPopfileName
netprop on the tf_objective_resource entity.

local resource = Entities.FindByClassname(null, "tf_objective_resource")
NetProps.SetPropString(resource, "m_iszMvMPopfileName", "New Mission Name Here")
Disabling Thriller taunt in Halloween

special taunt
attribute to all of the player's weapons, as that always prevents the Thriller taunt. The old way had a rare chance of failing to prevent the taunt. That old example remains here for prosperity.The following think function on a player will disable the Thriller taunt on Halloween mode and play the normal taunt instead.
function NoThrillerThink()
{
if (self.IsTaunting())
{
for (local scene; scene = Entities.FindByClassname(scene, "instanced_scripted_scene");)
{
local owner = NetProps.GetPropEntity(scene, "m_hOwner")
if (owner == self)
{
local name = NetProps.GetPropString(scene, "m_szInstanceFilename")
local thriller_name = self.GetPlayerClass() == TF_CLASS_MEDIC ? "taunt07" : "taunt06"
if (name.find(thriller_name) != null)
{
scene.Kill()
self.RemoveCond(TF_COND_TAUNTING)
self.Taunt(TAUNT_BASE_WEAPON, 0)
break
}
}
}
}
return -1
}
Applying paints to any entity
Cosmetic paints can be applied to any entity such as prop_dynamic by setting the owner entity to a tf_wearable (or any other econ entity). See this guide for a list of paint values (under Painting Robot Items).
local model_name = "models/player/items/demo/demo_ellis.mdl"
local origin = GetListenServerHost().GetOrigin()
local paint = 15132390
local wearable = Entities.CreateByClassname("tf_wearable")
NetProps.SetPropBool(wearable, "m_AttributeManager.m_Item.m_bInitialized", true)
wearable.SetAbsOrigin(origin)
wearable.AddAttribute("set item tint RGB", paint, -1)
wearable.DispatchSpawn()
wearable.EnableDraw()
local prop = SpawnEntityFromTable("prop_dynamic",
{
origin = origin,
model = model_name
})
prop.SetOwner(wearable)
Readying up teams for mp_tournament 1 in offline
Gamemodes for Casual must be tested with mp_tournament 1 and mp_tournament_stopwatch 0 to be considered for inclusion. However this usually requires 2 real players to ready up for their team, which may be undesirable for offline testing. As a workaround, the following code snippet can be executed to pick any player from both teams and spoof readying up.
local GameRules = Entities.FindByClassname(null, "tf_gamerules")
local MAX_CLIENTS = MaxClients().tointeger()
// 0 - not ready
// 1 - ready
local ready_state = 1
local red_check = false
local blue_check = false
for (local i = 1; i <= MAX_CLIENTS; i++)
{
local player = PlayerInstanceFromIndex(i)
if (!player)
continue
local team = player.GetTeam()
if (!(team & 2))
continue
if (team == 2)
{
if (red_check)
continue
red_check = true
}
else if (team == 3)
{
if (blue_check)
continue
blue_check = true
}
if (NetProps.GetPropBoolArray(GameRules, "m_bTeamReady", team))
continue
NetProps.SetPropBoolArray(GameRules, "m_bTeamReady", ready_state == 1, team)
SendGlobalGameEvent("tournament_stateupdate",
{
userid = player.entindex(),
readystate = ready_state,
namechange = 0,
oldname = " ",
newname = " ",
})
if (ready_state == 0)
{
NetProps.SetPropFloat(GameRules, "m_flRestartRoundTime", -1.0)
NetProps.SetPropBool(GameRules, "m_bAwaitingReadyRestart", true)
}
}
Placing Sapper on a Building
Placing a sapper on an entity isn't supposed to be done by manual means, however it can be spoofed by selecting a random player and pretending they "sapped" the building, and reverting everything on the same frame which makes it look seamless.

Weapon_Switch
.const MAX_WEAPONS = 8
function SapBuilding(building)
{
// select first player found
local player = Entities.FindByClassname(null, "player")
if (!player)
return
// find existing builder weapon if applicable
local player_class = player.GetPlayerClass()
local old_builder, old_slot
if (player_class == 8 || player_class == 9) // TF_CLASS_SPY or TF_CLASS_ENGINEER
{
for (local i = 0; i < MAX_WEAPONS; i++)
{
local held_weapon = NetProps.GetPropEntityArray(player, "m_hMyWeapons", i)
if (held_weapon == null)
continue
if (held_weapon.GetClassname() != "tf_weapon_builder")
continue
old_builder = held_weapon
old_slot = i
NetProps.SetPropEntityArray(player, "m_hMyWeapons", null, i)
break
}
}
local old_lifestate = NetProps.GetPropInt(player, "m_lifeState")
local old_team = player.GetTeam()
local building_team = building.GetTeam()
local enemy_team
if (building_team == 2) // red
enemy_team = 3
else if (building_team == 3) // blue
enemy_team = 2
else
enemy_team = 2
// spoof being alive and on opposite team
NetProps.SetPropInt(player, "m_lifeState", 0)
NetProps.SetPropInt(player, "m_iTeamNum", enemy_team)
// give sapper weapon
local weapon = Entities.CreateByClassname("tf_weapon_builder")
NetProps.SetPropInt(weapon, "m_AttributeManager.m_Item.m_iItemDefinitionIndex", 735)
NetProps.SetPropBool(weapon, "m_AttributeManager.m_Item.m_bInitialized", true)
NetProps.SetPropBool(weapon, "m_bValidatedAttachedEntity", true)
weapon.SetTeam(enemy_team)
weapon.DispatchSpawn()
NetProps.SetPropInt(weapon, "m_iObjectType", 3)
NetProps.SetPropInt(weapon, "m_iSubType", 3)
NetProps.SetPropIntArray(weapon, "m_aBuildableObjectTypes", 1, 3)
player.Weapon_Equip(weapon)
// teleport player to the building and hold the sapper
local old_origin = player.GetOrigin()
local old_angles = player.LocalEyeAngles()
local old_weapon = player.GetActiveWeapon()
player.SetAbsOrigin(building.GetOrigin() - Vector(64, 0, 0))
NetProps.SetPropVector(player, "pl.v_angle", Vector())
player.Weapon_Switch(weapon)
weapon.PrimaryAttack()
// remove owner from the sapper that was just placed
for (local sapper; sapper = Entities.FindByClassname(sapper, "obj_attachment_sapper");)
{
if (NetProps.GetPropEntity(sapper, "m_hBuiltOnEntity") == building)
{
NetProps.SetPropEntity(sapper, "m_hBuilder", null)
break
}
}
// revert all changes
if (old_builder)
NetProps.SetPropEntityArray(player, "m_hMyWeapons", old_builder, old_slot)
NetProps.SetPropInt(player, "m_lifeState", old_lifestate)
NetProps.SetPropInt(player, "m_iTeamNum", old_team)
player.Weapon_Switch(old_weapon)
NetProps.SetPropVector(player, "pl.v_angle", old_angles + Vector())
player.SetAbsOrigin(old_origin)
weapon.Destroy()
}
// example
local building = Entities.FindByClassname(null, "obj_dispenser")
if (building)
{
if (!NetProps.GetPropBool(building, "m_bHasSapper"))
SapBuilding(building)
}
The following code show an example of creating a primitive bot that navigates using the navmesh. When this script is executed, a bot will be spawned at the host player's crosshair. This bot will simply follow the player and path-find along the map layout. The code is heavily commented, and debug visualizations are available to see the path-finding algorithm in action.



// Constrains an angle into [-180, 180] range
function NormalizeAngle(target)
{
target %= 360.0
if (target > 180.0)
target -= 360.0
else if (target < -180.0)
target += 360.0
return target
}
// Approaches an angle at a given speed
function ApproachAngle(target, value, speed)
{
target = NormalizeAngle(target)
value = NormalizeAngle(value)
local delta = NormalizeAngle(target - value)
if (delta > speed)
return value + speed
else if (delta < -speed)
return value - speed
return value
}
// Converts a vector direction into angles
function VectorAngles(forward)
{
local yaw, pitch
if ( forward.y == 0.0 && forward.x == 0.0 )
{
yaw = 0.0
if (forward.z > 0.0)
pitch = 270.0
else
pitch = 90.0
}
else
{
yaw = (atan2(forward.y, forward.x) * 180.0 / Constants.Math.Pi)
if (yaw < 0.0)
yaw += 360.0
pitch = (atan2(-forward.z, forward.Length2D()) * 180.0 / Constants.Math.Pi)
if (pitch < 0.0)
pitch += 360.0
}
return QAngle(pitch, yaw, 0.0)
}
// Coordinate which is part of a path
class PathPoint
{
constructor(_area, _pos, _how)
{
area = _area
pos = _pos
how = _how
}
area = null // Which area does this point belong to?
pos = null // Coordinates of the point
how = null // Type of traversal. See Constants.ENavTraverseType
}
// The big boy that handles all our behavior
class Bot
{
function constructor(bot_ent, follow_ent)
{
bot = bot_ent
move_speed = 230.0
turn_rate = 5.0
search_dist_z = 128.0
search_dist_nearest = 128.0
path = []
path_index = 0
path_reach_dist = 16.0
path_follow_ent = follow_ent
path_follow_ent_dist = 50.0
path_target_pos = follow_ent.GetOrigin()
path_update_time_next = Time()
path_update_time_delay = 0.2
path_update_force = true
area_list = {}
seq_idle = bot_ent.LookupSequence("Stand_MELEE")
seq_run = bot_ent.LookupSequence("Run_MELEE")
pose_move_x = bot_ent.LookupPoseParameter("move_x")
debug = true
// Add behavior that will run every tick
AddThinkToEnt(bot_ent, "BotThink")
}
function UpdatePath()
{
// Clear out the path first
ResetPath()
// If there is a follow entity specified, then the bot will pathfind to the entity
if (path_follow_ent && path_follow_ent.IsValid())
path_target_pos = path_follow_ent.GetOrigin()
// Pathfind from the bot's position to the target position
local pos_start = bot.GetOrigin()
local pos_end = path_target_pos
local area_start = NavMesh.GetNavArea(pos_start, search_dist_z)
local area_end = NavMesh.GetNavArea(pos_end, search_dist_z)
// If either area was not found, try use the closest one
if (area_start == null)
area_start = NavMesh.GetNearestNavArea(pos_start, search_dist_nearest, false, true)
if (area_end == null)
area_end = NavMesh.GetNearestNavArea(pos_end, search_dist_nearest, false, true)
// If either area is still missing, then bot can't progress
if (area_start == null || area_end == null)
return false
// If the start and end area is the same, one path point is enough and all the expensive path building can be skipped
if (area_start == area_end)
{
path.append(PathPoint(area_end, pos_end, Constants.ENavTraverseType.NUM_TRAVERSE_TYPES))
return true
}
// Build list of areas required to get from the start to the end
if (!NavMesh.GetNavAreasFromBuildPath(area_start, area_end, pos_end, 0.0, Constants.ETFTeam.TEAM_ANY, false, area_list))
return false
// No areas found? Uh oh
if (area_list.len() == 0)
return false
// Now build points using the list of areas, which the bot will then follow
local area_target = area_list["area0"]
local area = area_target
local area_count = area_list.len()
// Iterate through the list of areas in order and initialize points
for (local i = 0; i < area_count && area != null; i++)
{
path.append(PathPoint(area, area.GetCenter(), area.GetParentHow()))
area = area.GetParent(); // Advances to the next connected area
}
// Reverse the list of path points as the area list is connected backwards
path.reverse()
// Now compute accurate path points, using adjacent points + direction data from nav
local path_first = path[0]
local path_count = path.len()
// First point is simply our current position
path_first.pos = bot.GetOrigin()
path_first.how = Constants.ENavTraverseType.NUM_TRAVERSE_TYPES // No direction specified
for (local i = 1; i < path_count; i++)
{
local path_from = path[i - 1]
local path_to = path[i]
// Computes closest point within the "portal" between adjacent areas
path_to.pos = path_from.area.ComputeClosestPointInPortal(path_to.area, path_to.how, path_from.pos)
}
// Add a final point so the bot can precisely move towards the end point when it reaches the final area
path.append(PathPoint(area_end, pos_end, Constants.ENavTraverseType.NUM_TRAVERSE_TYPES))
}
function AdvancePath()
{
// Check for valid path first
local path_len = path.len()
if (path_len == 0)
return false
local path_pos = path[path_index].pos
local bot_pos = bot.GetOrigin()
// Are we close enough to the path point to consider it as 'reached'?
if ((path_pos - bot_pos).Length2D() < path_reach_dist)
{
// Start moving to the next point
path_index++
if (path_index >= path_len)
{
// End of the line!
ResetPath()
return false
}
}
return true
}
function ResetPath()
{
area_list.clear()
path.clear()
path_index = 0
}
function Move()
{
// Recompute the path if forced to do so
if (path_update_force)
{
UpdatePath()
path_update_force = false
}
// Recompute path to our target if present
else if (path_follow_ent && path_follow_ent.IsValid())
{
// Is it time to re-compute the path?
local time = Time()
if (path_update_time_next < time)
{
// Check if target has moved far away enough
if ((path_target_pos - path_follow_ent.GetOrigin()).Length() > path_follow_ent_dist)
{
UpdatePath()
// Don't recompute again for a moment
path_update_time_next = time + path_update_time_delay
}
}
}
// Check and advance up our path
if (AdvancePath())
{
local path_pos = path[path_index].pos
local bot_pos = bot.GetOrigin()
// Direction towards path point
local move_dir = (path_pos - bot_pos)
move_dir.Norm()
// Convert direction into angle form
local move_ang = VectorAngles(move_dir)
// Approach new desired angle but only on the Y axis
local bot_ang = bot.GetAbsAngles()
move_ang.x = bot_ang.x
move_ang.y = ApproachAngle(move_ang.y, bot_ang.y, turn_rate)
move_ang.z = bot_ang.z
// Set our new position and angles
// Velocity is calculated from direction times speed, and converted from per-second to per-tick time
bot.SetAbsOrigin(bot_pos + (move_dir * move_speed * FrameTime()))
bot.SetAbsAngles(move_ang)
return true
}
return false
}
function Update()
{
// Try moving
if (Move())
{
// Moving, set the run animation
if (bot.GetSequence() != seq_run)
{
bot.SetSequence(seq_run)
bot.SetPoseParameter(pose_move_x, 1.0) // Set the move_x pose to max weight
}
}
else
{
// Not moving, set the idle animation
if (bot.GetSequence() != seq_idle)
{
bot.SetSequence(seq_idle)
bot.SetPoseParameter(pose_move_x, 0.0) // Clear the move_x pose
}
}
// Replay animation if it has finished
if (bot.GetCycle() > 0.99)
bot.SetCycle(0.0)
// Run animations
bot.StudioFrameAdvance()
bot.DispatchAnimEvents(bot)
// Visualize current path in debug mode
if (debug)
{
// Stay around for 1 tick
// Debugoverlays are created on 1st tick but start rendering on 2nd tick, hence this must be doubled
local frame_time = FrameTime() * 2.0
// Draw connected path points
local path_len = path.len()
if (path_len > 0)
{
local path_start_index = path_index
if (path_start_index == 0)
path_start_index++
for (local i = path_start_index; i < path_len; i++)
{
DebugDrawLine(path[i - 1].pos, path[i].pos, 0, 255, 0, true, frame_time)
}
}
// Draw areas from built path
foreach (name, area in area_list)
{
area.DebugDrawFilled(255, 0, 0, 30, frame_time, true, 0.0)
DebugDrawText(area.GetCenter(), name, false, frame_time)
}
}
return 0.0 // Think again next frame
}
function OnKilled()
{
// Change life state to "dying"
// The bot won't take any more damage, and sentries will stop targeting it
NetProps.SetPropInt(bot, "m_lifeState", 1)
// Reset health, preventing the default base_boss death behavior
bot.SetHealth(bot.GetMaxHealth() * 20)
// Custom death behavior can be added here
// For this example, turn into a ragdoll with the saved damage force
bot.BecomeRagdollOnClient(damage_force)
}
bot = null // The bot entity we belong to
move_speed = null // How fast to move
turn_rate = null // How fast to turn
search_dist_z = null // Maximum distance to look for a nav area downwards
search_dist_nearest = null // Maximum distance to look for any nearby nav area
path = null // List of BotPathPoints
path_index = null // Current path point bot is at, -1 if none
path_reach_dist = null // Distance to a path point to be considered as 'reached'
path_follow_ent = null // What entity to move towards
path_follow_ent_dist = null // Maximum distance after which the path is recomputed
// if follow entity's current position is too far from our target position
path_target_pos = null // Position where bot wants to navigate to
path_update_time_next = null // Timer for when to update path again
path_update_time_delay = null // Seconds to wait before trying to attempt to update path again
path_update_force = null // Force path recomputation on the next tick
area_list = null // List of areas built in path
seq_idle = null // Animation to use when idle
seq_run = null // Animation to use when running
pose_move_x = null // Pose parameter to set for running animation
damage_force = null // Damage force from the bot's last OnTakeDamage event
debug = null // When true, debug visualization is enabled
}
function BotThink()
{
// Let the bot class handle all the work
return self.GetScriptScope().my_bot.Update()
}
function BotCreate()
{
// Find point where player is looking
local player = GetListenServerHost()
local trace =
{
start = player.EyePosition(),
end = player.EyePosition() + (player.EyeAngles().Forward() * 32768.0),
ignore = player
}
if (!TraceLineEx(trace))
{
printl("Invalid bot spawn location")
return null
}
// Spawn bot at the end point
local bot = SpawnEntityFromTable("base_boss",
{
targetname = "bot",
origin = trace.pos,
model = "models/bots/heavy/bot_heavy.mdl",
playbackrate = 1.0, // Required for animations to be simulated
health = 300
})
// Add scope to the entity
bot.ValidateScriptScope()
// Append custom bot class and initialize its behavior
bot.GetScriptScope().my_bot <- Bot(bot, player)
// Fix the default step height which is too high
bot.AcceptInput("SetStepHeight", "18", null, null)
return bot
}
function OnScriptHook_OnTakeDamage(params)
{
local ent = params.const_entity
local inf = params.inflictor
if (ent.IsPlayer() && HasBotScript(inf) && params.damage_type == 1)
{
// Don't crush the player if a bot pushes them into a wall
params.damage = 0
}
if (ent.GetClassname() == "base_boss" && HasBotScript(ent))
{
// Save the damage force into the bot's data
ent.GetScriptScope().my_bot.damage_force = params.damage_force
}
}
function OnGameEvent_npc_hurt(params)
{
local ent = EntIndexToHScript(params.entindex)
if (HasBotScript(ent))
{
// Check if a bot is about to die
if ((ent.GetHealth() - params.damageamount) <= 0)
{
// Run the bot's OnKilled function
ent.GetScriptScope().my_bot.OnKilled()
}
}
}
function HasBotScript(ent)
{
// Return true if this entity has the my_bot script scope
return (ent.GetScriptScope() != null && ent.GetScriptScope().my_bot != null)
}
__CollectGameEventCallbacks(this)
BotCreate()
See also
List of Team Fortress 2 Script Functions
- List of Script Libraries, these can also be useful as examples
- (WIP) Community Contributions, Contributions submitted by TF2Maps