Adding a New Weapon to Your TF2 Mod
This tutorial outlines how to add a new weapon to your TF2 mod. For the purposes of this tutorial, we will create a new scattergun for the scout.
This tutorial assumes you have an unmodified TF2 mod up and running, and you are able to compile and test your changes. Knowledge of C++ will be helpful, as this tutorial will modify the code to allow loading in a custom item schema.
<mod_dir>) is the folder which contains gameinfo.txt.
A video version of this tutorial may be viewed here.
Introduction
To add weapons to your mod, you must define them in your item schema. The item schema (items_game.txt) is a KeyValues file that contains metadata for all items in Team Fortress 2. You can add items in one of two ways:
- Directly modify the existing
items_game.txtfile located atsteam/steamapps/common/Team Fortress 2/tf/scripts/items/items_game.txt, or - Create your own item schema and adjust the C++ code to load your custom schema instead of the default one.
This tutorial will take the second approach, as the official items_game.txt file is extremely large (over 262,000 lines) and difficult to manage. Keeping your custom weapons in a separate file makes your mod more organized and maintainable, whilst preventing merge conflicts from future updates. We will add a directive at the top of our item schema to include items_game.txt as a base, so the mod retains all existing TF items. Additionally, we need to modify the code to give the player our new weapon (because it isn't in our steam inventory).
For this tutorial, we will create a new scattergun for the Scout called "The Blood Letter". It will act just like the stock scattergun but with a 20% damage penalty, and players shot by the weapon will bleed for 5 seconds. In the item schema, we will define The Blood Letter's name and description, make it inherit behaviour from the scattergun, and give it two attributes: bleeding duration and damage penalty. Feel free to choose any weapon and design it how you wish. A full list of item attributes is available here. As you will see below, this system is very flexible and allows you to prototype new weapon concepts quickly (instead of modifying the code, then waiting for the code to recompile). Defining your weapon as a C++ class is only necessary if your weapon needs advanced functionality that no weapon already implements. Additionally, you can define custom item attributes in the code.
Step 1: Add Your Weapon Strings to the Localization File
First, we need to define the weapon name and description as it will appear in the UI.
Open the localization file <mod_dir>/resource/mod_tf_english.txt and add the following strings:
my_mod, this file must be renamed to my_mod_english.txt. Otherwise, an error in the console will appear at startup stating the localization file could not be found.
"lang"
{
"Language" "English"
"Tokens"
{
"TF_MyStrings" "Your strings go in this file."
"TF_BloodLetter" "The Blood Letter"
"TF_BloodLetterDesc" "This is the description for the Blood Letter."
}
}Here, we create two tokens, "TF_BloodLetter" and "TF_BloodLetterDesc" and assign strings to them. You are free to name your tokens however you wish. We will use them inside the item schema by prefixing them with an '#' (e.g #TF_BloodLetter).
Step 2: Define the Weapon in the Item Schema
Create a file named <mod_dir>/scripts/items/items_mod_tf.txt. Create the folder(s) if they do not exist. You may give it any name you wish, as long as it does not contain any spaces. Remember what you call it, as you will need to refer to it later in the C++ code. For the purposes of this tutorial, it will be called items_mod_tf.txt. This will be our item schema.
Open this file in a text editor, and add the following code:
#base items_game.txt
"items_game"
{
"attributes"
{
}
"items"
{
}
}Next, we will define our weapon inside the "items" object:
"items"
{
"21000"
{
"name" "Bloodletter"
"mod_tf_item" "1"
"prefab" "weapon_scattergun"
"item_name" "#TF_BloodLetter"
"item_description" "#TF_BloodLetterDesc"
"item_logname" "Bloodletter"
"item_quality" "unique"
"attributes"
{
"bleeding duration"
{
"attribute_class" "bleeding_duration"
"value" "5"
}
"damage penalty"
{
"attribute_class" "mult_dmg"
"value" "0.80"
}
}
}
}This is a rundown of what each keyvalue does:
- "21000" - This is the item ID. Check
items_game.txtto ensure you don't collide with any existing item IDs. 21000 is a good starting point. - "name" - This is the internal name used by the engine.
- "mod_tf_item" - Indicates this is a mod-specific weapon. Will be used in the C++ code below.
- "prefab" - The weapon/prefab to inherit from. We want to inherit from the Scattergun, so we select "weapon_scattergun". This prefab is defined on line ~20246 of
items_game.txt, and all keyvalues defined in this prefab also apply to our weapon, unless our weapon overrides it. - "item_name" - This is our weapon's display name, the one which will appear in the UI.
#TF_BloodLetterwas defined in the Localization file. - "item_description" - The description text.
- "item_logname" - The weapon name as it appears in the console.
- "item_quality" - The Item Quality. All possible quality types are defined on line ~14 of
items_game.txt. - "attributes" - This is an object containing all the weapon's attributes. A list of all possible attributes can be found here. The Blood Letter has a 5 second bleed and 20% damage penalty attribute. The
attribute_classmust be set correctly depending on the attribute name.
there's also some additional keyvalues not listed in the example, here's the rundown for those:
- "image_inventory" - This is the image that will be used for the backpack icon.
- "image_inventory_size_w" - This sets the width of the backpack icon.
- "image_inventory_size_h" - This sets the height of the backpack icon.
- "image_inventory_size_h" - This sets the height of the backpack icon.
- "item_slot" - This sets the item slot of a given item. (i.e. primary, secondary, melee, pda1, pda2)
- "anim_slot" - This value sets the current animation set used in third person.
- "model_player" - This value sets the model of a give weapon.
- "mouse_pressed_sound" - Sound played when switching from this item to a different one.
- "drop_sound" - Sound played when switching to this item.
- "used_by_classes" - this controls which classes can equip that item, and on what slots they get equipped, set to 1 to use the default value set by "item_slot".
- "visuals" - This field can contain various stuff, ranging from sounds, styles and team skins.
List of Visuals
Sounds
| Value | Description |
|---|---|
| sound_single_shot | The sound played when shooting |
| sound_melee_miss | The sound played when swinging the melee and not hitting anything |
| sound_melee_hit | The sound played when hitting a player with a melee |
| sound_melee_hit_world | The sound played when hitting the world with a melee |
| sound_burst | The sound used for critical attacks |
List of valid classes
| class | example values |
|---|---|
| scout | primary, secondary, melee |
| soldier | primary, secondary, melee |
| pyro | primary, secondary, melee |
| demoman | primary, secondary, melee |
| heavy | primary, secondary, melee |
| engineer | primary, secondary, melee, pda2 |
| medic | primary, secondary, melee |
| sniper | primary, secondary, melee |
| spy | secondary, melee, building, pda2 |
If you want to define some other attribute not listed here, you can always refer to the real item schema located in steam/steamapps/common/Team Fortress 2/tf/scripts/items/items_game.txt.
Step 3: Modify the Code to Read from the New Item Schema
Currently, the code reads from items_game.txt only. We will change the code to read our item schema.
Open Visual Studio (or your preferred text editor), and edit the following files. Line numbers are approximate and subject to change.
econ_item_schema.h
Around line 1604, inside the class definition for CEconItemDefinition, add:
bool m_bBaseItem;
bool m_bModItem;
bool m_bImported;
Around line 1283, add a getter for m_bModItem called IsModItem:
bool IsBaseItem( void ) const { return m_bBaseItem; }
bool IsModItem(void) const { return m_bModItem; }
bool IsBundle( void ) const { return m_BundleInfo != NULL; }
Around line 2615, inside the class definition for CEconItemSchema add a getter for m_mapModItems (a variable we will define next):
typedef CUtlMap<int, CEconItemDefinition*, int> BaseItemDefinitionMap_t;
const BaseItemDefinitionMap_t &GetBaseItemDefinitionMap() const { return m_mapBaseItems; }
typedef CUtlMap<int, CEconItemDefinition*, int> ModItemDefinitionMap_t;
const ModItemDefinitionMap_t& GetModItemDefinitionMap() const { return m_mapModItems; }
Around line 2930, still inside CEconItemSchema, we can define m_mapModItems.
// List of all base items, is a sublist of mapItems
BaseItemDefinitionMap_t m_mapBaseItems;
// List of all mod items, is a sublist of mapItems
ModItemDefinitionMap_t m_mapModItems;
econ_item_schema.cpp
Around line 2309, in the initialiser list for CEconItemDefinition, add:
m_bModItem(false),
Around line 3181, add:
// Creation data
m_bHidden = m_pKVItem->GetInt( "hidden", 0 ) != 0;
m_bShouldShowInArmory = m_pKVItem->GetInt( "show_in_armory", 0 ) != 0;
m_bBaseItem = m_pKVItem->GetInt( "baseitem", 0 ) != 0;
m_bModItem = m_pKVItem->GetInt("mod_tf_item", 0) != 0; // "mod_tf_item" matches the key we added in the item schema
m_pszItemLogClassname = m_pKVItem->GetString( "item_logname", NULL );
m_pszItemIconClassname = m_pKVItem->GetString( "item_iconname", NULL );
m_pszDatabaseAuditTable = m_pKVItem->GetString( "database_audit_table", NULL );
m_bImported = m_pKVItem->FindKey( "import_from" ) != NULL;
Around line 3810, inside the initialiser list of CEconItemSchema, add:
, m_mapModItems(DefLessFunc(int))
Around line 4305, Inside CEconItemSchema::Reset add:
m_mapModItems.Purge();
Around line 4425, CEconItemSchema::BInitTextBuffer, comment out the if statement and replace with:
//-----------------------------------------------------------------------------
// Initializes the schema, given KV in text form
//-----------------------------------------------------------------------------
bool CEconItemSchema::BInitTextBuffer( CUtlBuffer &buffer, CUtlVector<CUtlString> *pVecErrors /* = NULL */ )
{
// Save off the hash into a global variable, so VAC can check it
// later
GenerateHash( g_sha1ItemSchemaText, buffer.Base(), buffer.TellPut() );
Reset();
m_pKVRawDefinition = new KeyValues( "CEconItemSchema" );
//if ( m_pKVRawDefinition->LoadFromBuffer( NULL, buffer ) )
// load the custom item schema instead. This, in turn, still loads the base schema (first line of our item schema is '#base items_game.txt').
if (m_pKVRawDefinition->LoadFromFile(g_pFullFileSystem, "scripts/items/items_mod_tf.txt", "GAME")) // IMPORTANT: make sure the path matches the name of your item schema!
{
return BInitSchema( m_pKVRawDefinition, pVecErrors )
&& BPostSchemaInit( pVecErrors );
}
if ( pVecErrors )
{
pVecErrors->AddToTail( "Error parsing keyvalues" );
}
return false;
}
Around line 5282, in CEconItemSchema::BInitItems add:
bool CEconItemSchema::BInitItems( KeyValues *pKVItems, CUtlVector<CUtlString> *pVecErrors )
{
m_mapItems.PurgeAndDeleteElements();
m_mapItemsSorted.Purge();
m_mapToolsItems.Purge();
m_mapPaintKitTools.Purge();
m_mapBaseItems.Purge();
m_mapModItems.Purge();
m_vecBundles.Purge();
m_mapQuestObjectives.PurgeAndDeleteElements();
...
Later in CEconItemSchema::BInitItems, around line 5353, find if ( pItemDef->IsBaseItem() ) and add below it:
}
if ( pItemDef->IsBaseItem() )
{
m_mapBaseItems.Insert( nItemIndex, pItemDef );
}
if (pItemDef->IsModItem())
{
m_mapModItems.Insert(nItemIndex, pItemDef);
}
// Cache off bundles for the link phase below.
...
tf_item_inventory.h
On line 199, add:
CEconItemView* AddModItem(int id);
On line 224, add:
CUtlVector<CEconItemView*> m_pModLoadoutItems;
On line 219, add:
int GetModItemCount() { return m_pModLoadoutItems.Count(); }
CEconItemView* GetModItem(int iIndex) { return m_pModLoadoutItems[iIndex]; }
tf_item_inventory.cpp
Find TFInventoryManager::~CTFInventoryManager (around line 220), and add:
CTFInventoryManager::~CTFInventoryManager( void )
{
m_pBaseLoadoutItems.PurgeAndDeleteElements();
// Purge our mod items as well.
m_pModLoadoutItems.PurgeAndDeleteElements();
}
Around line 232, write the implementation for CTFInventoryManager::AddModItem:
//-----------------------------------------------------------------------------
// Purpose: Generate Mod Items in backpack
//-----------------------------------------------------------------------------
CEconItemView* CTFInventoryManager::AddModItem( int id )
{
CEconItemView* pItemView = new CEconItemView;
CEconItem* pItem = new CEconItem;
pItem->m_ulID = id;
pItem->m_unAccountID = 0;
pItem->m_unDefIndex = id;
pItemView->Init(id, AE_USE_SCRIPT_VALUE, AE_USE_SCRIPT_VALUE, false);
pItemView->SetItemID(id);
pItemView->SetNonSOEconItem(pItem);
m_pModLoadoutItems.AddToTail(pItemView);
return pItemView;
}
Find CTFInventoryManager::GenerateBaseItems (around line 250), and add:
//-----------------------------------------------------------------------------
// Purpose: Generate & store the base item details for each class & loadout slot
//-----------------------------------------------------------------------------
void CTFInventoryManager::GenerateBaseItems( void )
{
// Purge our lists and make new
m_pBaseLoadoutItems.PurgeAndDeleteElements();
// Load a base top level invalid item
{
m_pDefaultItem = new CEconItemView;
m_pDefaultItem->Invalidate();
}
const CEconItemSchema::BaseItemDefinitionMap_t& mapItems = GetItemSchema()->GetBaseItemDefinitionMap();
int iStart = 0;
for ( int it = iStart; it != mapItems.InvalidIndex(); it = mapItems.NextInorder( it ) )
{
CEconItemView *pItem = new CEconItemView;
pItem->Init( mapItems[it]->GetDefinitionIndex(), AE_USE_SCRIPT_VALUE, AE_USE_SCRIPT_VALUE, false );
m_pBaseLoadoutItems.AddToTail( pItem );
}
// Similarly, add our mod items from the item schema.
m_pModLoadoutItems.PurgeAndDeleteElements();
const CEconItemSchema::BaseItemDefinitionMap_t& mapItemsMod = GetItemSchema()->GetModItemDefinitionMap();
iStart = 0;
if (mapItemsMod.Count() != 0)
{
for (int it = iStart; it != mapItemsMod.InvalidIndex(); it = mapItemsMod.NextInorder(it))
AddModItem(mapItemsMod[it]->GetDefinitionIndex());
Msg("Loaded %i mod items.\n", mapItemsMod.Count());
}
}
Around line 297, inside CTFInventoryManager::EquipItemInLoadout add:
bool CTFInventoryManager::EquipItemInLoadout( int iClass, int iSlot, itemid_t iItemID )
{
if ( !steamapicontext || !steamapicontext->SteamUser() )
return false;
// If they pass in a INVALID_ITEM_ID item id, we're just clearing the loadout slot
if ( iItemID == INVALID_ITEM_ID )
return m_LocalInventory.ClearLoadoutSlot( iClass, iSlot );
CEconItemView *pItem = m_LocalInventory.GetInventoryItemByItemID( iItemID );
if (iItemID < 100000)
{
int count = TFInventoryManager()->GetModItemCount();
for (int i = 0; i < count; i++)
{
pItem = TFInventoryManager()->GetModItem(i);
if (pItem && pItem->GetItemDefIndex() == iItemID)
break;
}
}
if ( !pItem )
return false;
// We check for validity on the GC when we equip items, but we can't really trust anyone
// and so we check here as well.
if ( !AreSlotsConsideredIdentical( pItem->GetStaticData()->GetEquipType(), pItem->GetStaticData()->GetLoadoutSlot(iClass), iSlot ) )
{
return false;
}
if ( !pItem->GetStaticData()->CanBeUsedByClass( iClass ) )
{
return false;
}
// Equip the new item
UpdateInventoryEquippedState( &m_LocalInventory, iItemID, iClass, iSlot );
// TODO: Prediction
// Item has been moved, so update our loadout.
//m_LoadoutItems[iClass][iSlot] = iItemID;
return true;
}
On line 369, inside CTFInventoryManager::GetAllUsableItemsForSlot, after the for loop, add:
int CTFInventoryManager::GetAllUsableItemsForSlot( int iClass, int iSlot, CUtlVector<CEconItemView*> *pList )
{
bool bIsAccountIndex = iClass == GEconItemSchema().GetAccountIndex();
if ( bIsAccountIndex )
{
Assert( IsQuestSlot( iSlot ) );
}
else
{
Assert( iClass >= TF_FIRST_NORMAL_CLASS && iClass < TF_CLASS_COUNT );
Assert( iSlot >= -1 && iSlot < CLASS_LOADOUT_POSITION_COUNT );
}
int iCount = m_LocalInventory.GetItemCount();
for ( int i = 0; i < iCount; i++ )
{
CEconItemView *pItem = m_LocalInventory.GetItem(i);
CTFItemDefinition *pItemData = pItem->GetStaticData();
if ( bIsAccountIndex != ( pItemData->GetEquipType() == EEquipType_t::EQUIP_TYPE_ACCOUNT ) )
continue;
if ( !bIsAccountIndex && !pItemData->CanBeUsedByClass(iClass) )
continue;
// Passing in iSlot of -1 finds all items usable by the class
if ( iSlot >= 0 && pItem->GetStaticData()->GetLoadoutSlot( iClass ) != iSlot )
continue;
// Ignore unpack'd items
if ( IsUnacknowledged( pItem->GetInventoryPosition() ) )
continue;
pList->AddToTail( m_LocalInventory.GetItem(i) );
}
// go through our mod items and verify if we can equip them at this slot.
iCount = m_pModLoadoutItems.Count();
for (int i = 0; i < iCount; i++)
{
CEconItemView* pItem = m_pModLoadoutItems[i];
CTFItemDefinition* pItemData = pItem->GetStaticData();
if (!bIsAccountIndex && !pItemData->CanBeUsedByClass(iClass))
continue;
if (iSlot >= 0 && pItem->GetStaticData()->GetLoadoutSlot(iClass) != iSlot)
continue;
pList->AddToTail(pItem);
}
return pList->Count();
}
Around line 1095, replace CTFPlayerInventory::EquipLocal with:
void CTFPlayerInventory::EquipLocal(uint64 ulItemID, equipped_class_t unClass, equipped_slot_t unSlot)
{
// These interactions normally result from a round-trip with the GC.
// We will never get those messages, so we do everything locally.
// Unequip whatever was previously in the slot.
itemid_t ulPreviousItem = m_LoadoutItems[unClass][unSlot];
if (ulPreviousItem != 0 && ulPreviousItem < 100000)
{
int count = TFInventoryManager()->GetModItemCount();
for (int i = 0; i < count; i++)
{
CEconItemView* pItem = TFInventoryManager()->GetModItem(i);
if (pItem && pItem->GetItemDefIndex() == ulPreviousItem)
pItem->GetSOCData()->UnequipFromClass(unClass);
}
CEconItemView* pPreviousItem = GetInventoryItemByItemID(ulPreviousItem);
if (pPreviousItem) {
pPreviousItem->GetSOCData()->UnequipFromClass(unClass);
}
}
else
{
CEconItemView* pPreviousItem = GetInventoryItemByItemID(ulPreviousItem);
if (pPreviousItem)
pPreviousItem->GetSOCData()->UnequipFromClass(unClass);
}
// Equip the new item and add it to our loadout.
if (ulItemID < 100000)
{
int count = TFInventoryManager()->GetModItemCount();
CEconItemView* pItem;
for (int i = 0; i < count; i++)
{
pItem = TFInventoryManager()->GetModItem(i);
if (pItem && pItem->GetItemDefIndex() == ulItemID)
{
pItem->GetSOCData()->Equip(unClass, unSlot);
break;
}
}
if (!pItem)
{
pItem = TFInventoryManager()->AddModItem(ulItemID);
if (pItem && pItem->GetItemDefIndex() == ulItemID)
pItem->GetSOCData()->Equip(unClass, unSlot);
}
}
else
{
CEconItemView* pItem = GetInventoryItemByItemID(ulItemID);
if (pItem)
pItem->GetSOCData()->Equip(unClass, unSlot);
}
m_LoadoutItems[unClass][unSlot] = ulItemID;
#ifdef CLIENT_DLL
int activePreset = m_ActivePreset[unClass];
m_PresetItems[activePreset][unClass][unSlot] = ulItemID;
GTFGCClientSystem()->LocalInventoryChanged();
#endif
}
Around line 1552, inside CTFPlayerInventory::GetItemInLoadout, add to the end:
CEconItemView *CTFPlayerInventory::GetItemInLoadout( int iClass, int iSlot )
{
if ( iSlot < 0 || iSlot >= CLASS_LOADOUT_POSITION_COUNT )
return NULL;
if ( iClass == GEconItemSchema().GetAccountIndex() )
{
return GetInventoryItemByItemID( m_AccountLoadoutItems[ iSlot ] );
}
else
{
if ( iClass < TF_FIRST_NORMAL_CLASS || iClass >= TF_LAST_NORMAL_CLASS )
return NULL;
// If we don't have an item in the loadout at that slot, we return the base item
if ( m_LoadoutItems[iClass][iSlot] != LOADOUT_SLOT_USE_BASE_ITEM )
{
CEconItemView *pItem = GetInventoryItemByItemID( m_LoadoutItems[iClass][iSlot] );
// To protect against users lying to the backend about the position of their items,
// we need to validate their position on the server when we retrieve them.
if ( pItem && AreSlotsConsideredIdentical( pItem->GetStaticData()->GetEquipType(), pItem->GetStaticData()->GetLoadoutSlot( iClass ), iSlot ) )
return pItem;
// check mod items
if (m_LoadoutItems[iClass][iSlot] < 100000)
{
int count = TFInventoryManager()->GetModItemCount();
for (int i = 0; i < count; i++)
{
CEconItemView* pItem = TFInventoryManager()->GetModItem(i);
if (pItem && pItem->GetItemDefIndex() == m_LoadoutItems[iClass][iSlot])
{
if (pItem && AreSlotsConsideredIdentical(pItem->GetStaticData()->GetEquipType(), pItem->GetStaticData()->GetLoadoutSlot(iClass), iSlot))
return pItem;
}
}
return TFInventoryManager()->AddModItem(m_LoadoutItems[iClass][iSlot]);
}
}
}
return TFInventoryManager()->GetBaseItemForClass( iClass, iSlot );
}
tf_gc_server.cpp
Around line 4391, inside CTFGCServerSystem::SDK_ApplyLocalLoadout, at the end of the method, comment out and add:
/*
CEconItem* pItem = (CEconItem*) pItemCache->FindSharedObject(soIndex);
if (pItem) {
pTFInventory->EquipLocal(uItemId, iClass, iSlot);
}
else {
Warning("Failed to find item %llu in shared object, but client says it should be equipped by [%i] in slot [%i].\n", uItemId, iClass, iSlot);
}
*/
pTFInventory->EquipLocal(uItemId, iClass, iSlot);
}
Around line 3605, move the #endif // USE_MVM_TOUR directive to just before the end of CTFGCServerSystem::SendMvMVictoryResult (around line 3618):
...
#ifdef USE_MVM_TOUR
if ( !m_mvmVictoryInfo.m_sMannUpTourOfDuty.IsEmpty() )
{
msg.set_tour_name_mannup( m_mvmVictoryInfo.m_sMannUpTourOfDuty );
}
//#endif // USE_MVM_TOUR <--- COMMENT OUT
msg.set_lobby_id( m_mvmVictoryInfo.m_nLobbyId );
msg.set_event_time( m_mvmVictoryInfo.m_tEventTime );
FOR_EACH_VEC( m_mvmVictoryInfo.m_vPlayerIds, iMember )
{
CMsgMvMVictory_Player *pMsgPlayer = msg.add_players();
pMsgPlayer->set_steam_id( m_mvmVictoryInfo.m_vPlayerIds[ iMember ]);
pMsgPlayer->set_squad_surplus( m_mvmVictoryInfo.m_vSquadSurplus[ iMember ] );
}
ReliableMsgQueue().Enqueue( pReliable );
}
#endif // USE_MVM_TOUR <--- MOVE HERE
}
...
loadout_preset_panel.cpp
Finally, around line 235, add this check inside CLoadoutPresetPanel::UpdatePresetButtonStates():
void CLoadoutPresetPanel::UpdatePresetButtonStates()
{
if (!steamapicontext->SteamUser())
return;
equipped_preset_t unEquippedPresetID = GetSelectedPresetID();
...
Step 4. Compile and Test Your Mod
Compile the code (In Visual Studio, from the top bar: Build > Rebuild Solution) and then run your mod.
Open the loadout for the Scout and see if you can equip your weapon in the primary slot.
Create a server and test your weapon in game. You can test your weapon with bots by typing sv_cheats 1 then bot add in the console.
