Item System

CityRP's item system uses a modern template-based architecture that separates reusable behavior (bases) from item configuration (definitions). This document covers the modern item system for developers with practical examples from the codebase.

Overview

The item system consists of: - Item Bases (Templates): Reusable behavior patterns that define how item types work - Item Definitions: Configuration-only files that create actual items using bases - Action Builder System: Modern replacement for legacy onUse/canUse functions - Lifecycle Hooks: Clean event system for item registration, creation, and player events - Auto-Action Conversion: Backwards compatibility layer for legacy items

Core Concepts

Bases vs Definitions

Item Bases (Templates): - Define reusable behavior patterns - Located in gamemode/core/items/base/ or plugins/*/items/ - Use :DisableAutoActions() to prevent legacy conversion - Define lifecycle hooks and action builders - Not directly usable by players

Item Definitions: - Configuration-only files that create actual items - Use existing bases with specific properties - Located in gamemode/core/items/ or plugins/*/items/ - Directly usable by players

Available Core Bases

  • base_item: Root base for all items (basic properties and lifecycle)
  • base_equip: Equipment items with slot management
  • base_swep: Weapon items that give SWEPs when equipped
  • base_place: Items that can be placed as entities in the world
  • base_clothing: Clothing items with bodygroup/skin support

Creating Item Bases

Basic Base Structure

--- @class base_mytype : base_item
--- @field SetMyProperty fun(self: self, value: type): self Description of property
--- @field GetMyProperty fun(self: self): type Gets the property value
local ITEM = ITEM:New("base_parent")
    :SetName("Base Template Name")
    :SetCost(0)
    :SetStore(0)
    :SetSize(1)
    :SetCategory("Category") --[[@as base_mytype]]
    :DisableAutoActions()

    -- Auto-generate getter/setter for custom properties
    :AutoFunction("MyProperty", "myProperty")(defaultValue)

-- Lifecycle hooks
function ITEM:ItemRegistered()
    -- Called once when this base is registered
    -- Use for one-time setup like registering hooks
end

function ITEM:ItemCreated(item)
    -- Called when an instance of this item is created
    -- Use for per-item initialization
end

function ITEM:PlayerSpawnHook(client, isInitial)
    -- Called when a player holding this item spawns
end

function ITEM:PlayerDeathHook(client, attacker, dmgInfo)
    -- Called when a player holding this item dies
end

-- Action definitions
local function UseCondition(baseItem, item, client, ...)
    if not client:Alive() then
        return false, "You must be alive to use this item"
    end
    return true
end

local function UseAction(baseItem, item, client, ...)
    client:Notify("Used " .. item:GetName())
    -- Return true to consume the item, false to keep it
    return true
end

ITEM:AddAction("Use")
    :SetName("Use Item")
    :SetIcon("icon16/accept.png")
    :SetCondition(UseCondition)
    :SetAction(UseAction)
    :SetSortRank(10)
    :Build()

ITEM:AutoRegister()

Advanced Base Example

--- @class base_consumable : base_item
--- @field SetHealAmount fun(self: self, amount: integer): self Sets heal amount
--- @field GetHealAmount fun(self: self): integer Gets heal amount
local ITEM = ITEM:New("base_item")
    :SetName("Base Consumable")
    :SetCategory("Consumables")
    :SetSize(1)
    :DisableAutoActions()

    -- Custom properties with auto-generated getters/setters
    :AutoFunction("HealAmount", "healAmount")(25)
    :AutoFunction("ConsumeSound", "consumeSound")("items/medshot4.wav")
    :AutoFunction("ConsumeTime", "consumeTime")(2.0)

-- Example: Consumption logic with validation
local function ConsumeCondition(baseItem, item, client)
    if not client:Alive() then
        return false, "You must be alive to consume this"
    end

    if client:Health() >= client:GetMaxHealth() then
        return false, "You are already at full health"
    end

    return true
end

local function ConsumeAction(baseItem, item, client)
    local healAmount = baseItem:GetHealAmount()
    local sound = baseItem:GetConsumeSound()

    -- Heal the player
    client:SetHealth(math.min(client:Health() + healAmount, client:GetMaxHealth()))

    -- Play consumption sound
    if sound then
        client:EmitSound(sound)
    end

    -- Notify player
    client:Notify("Restored " .. healAmount .. " health")

    return true -- Consume the item
end

-- Example: Build consumption action
ITEM:AddAction("Use")
    :SetName("Consume")
    :SetIcon("icon16/heart.png")
    :SetCondition(ConsumeCondition)
    :SetAction(ConsumeAction)
    :SetSortRank(1)
    :Build()

ITEM:AutoRegister()

Creating Item Definitions

Basic Item Definition

-- Example: Simple item definition using a base
ITEM:New("base_consumable")
    :SetName("Health Potion")
    :SetDescription("Restores 50 health when consumed")
    :SetModel("models/props_junk/PopCan01a.mdl")
    :SetCost(100)
    :SetSize(2)
    :SetCategory("Medical") --[[@as base_consumable]]
    :SetHealAmount(50) --[[@as base_consumable]]
    :SetConsumeSound("items/medshot4.wav") --[[@as base_consumable]]
    :AutoRegister()

Complex Item Definition

-- Example: Equipment item with multiple properties
ITEM:New("base_equip")
    :SetName("Police Vest")
    :SetDescription("Protective vest for law enforcement")
    :SetModel("models/props_c17/BriefCase001a.mdl")
    :SetCost(500)
    :SetSize(4)
    :SetCategory("Equipment") --[[@as base_equip]]
    :SetEquipSlot("vest") --[[@as base_equip]]
    :SetDropOnDeath(DEATH_DROP_EQUIPPED) --[[@as base_equip]]
    :AutoRegister()

-- Example: Real weapon item from codebase style
ITEM:New("base_swep")
    :SetName("Crowbar")
    :SetDescription("A sturdy crowbar for breaking things")
    :SetModel("models/weapons/w_crowbar.mdl")
    :SetCost(150)
    :SetSize(3)
    :SetCategory("Weapons") --[[@as base_swep]]
    :SetSwepClass("cityrp_crowbar") --[[@as base_swep]]
    :SetEquipSlot("primary") --[[@as base_swep]]
    :SetDropOnDeath(DEATH_DROP_EQUIPPED) --[[@as base_swep]]
    :AutoRegister()

Action Builder System

Action Structure

Actions define how players can interact with items. Each action has: - ID: Unique identifier for the action - Name: Display name shown to players - Icon: Icon shown in menus - Condition: Function that determines if action is available - Action: Function that performs the action - Sort Rank: Display order (lower numbers first)

Building Actions

-- Example: Define condition function
local function CanUseItem(baseItem, item, client, ...)
    -- Return true to allow, false + reason to deny
    if not client:Alive() then
        return false, "You must be alive"
    end

    if client:GetMoveType() == MOVETYPE_NOCLIP then
        return false, "Cannot use while in noclip"
    end

    return true
end

-- Example: Define action function
local function UseItem(baseItem, item, client, ...)
    -- Perform the action
    client:Notify("Used " .. item:GetName())

    -- Return true to consume item, false to keep it
    return false
end

-- Example: Build the action
ITEM:AddAction("Use")
    :SetName("Use Item")
    :SetIcon("icon16/accept.png")
    :SetCondition(CanUseItem)
    :SetAction(UseItem)
    :SetSortRank(10)
    :Build()

Standard Action Types

Common Actions: - Use: Primary item interaction - Drop: Drop item on ground - Give: Give item to another player - Remove: Delete item from inventory - Equip: Equip item (for equipment) - Unequip: Unequip item (for equipment) - Place: Place item as entity (for placeable items)

Custom Actions

-- Example: Custom repair action with realistic validation
local function RepairCondition(baseItem, item, client, target)
    if not IsValid(target) or not target:IsVehicle() then
        return false, "Must target a vehicle"
    end

    if target:GetPos():Distance(client:GetPos()) > 200 then
        return false, "Too far from vehicle"
    end

    if target:GetHealthFraction() >= 1.0 then
        return false, "Vehicle is already fully repaired"
    end

    return true
end

local function RepairAction(baseItem, item, client, target)
    -- Repair the vehicle
    target:SetHealth(target:GetMaxHealth())
    client:Notify("Repaired " .. (target.PrintName or target:GetClass()))

    -- Play repair sound and effect
    client:EmitSound("weapons/357/357_reload4.wav")

    return true -- Consume repair kit
end

-- Example: Build the custom action
ITEM:AddAction("Repair")
    :SetName("Repair Vehicle")
    :SetIcon("icon16/wrench.png")
    :SetCondition(RepairCondition)
    :SetAction(RepairAction)
    :SetSortRank(5)
    :Build()

Lifecycle Hooks

Item Registration

function ITEM:ItemRegistered()
    -- Called once when item base/definition is registered
    -- Use for one-time setup
    hook.Add("PlayerSpawn", "MyItem_PlayerSpawn", function(client)
        -- Global hook logic
    end)
end

Item Creation

function ITEM:ItemCreated(item)
    -- Called when an instance of this item is created
    -- Use for per-item initialization
    item:SetData("createdTime", os.time())
    item:SetData("serialNumber", math.random(100000, 999999))
end

Player Events

function ITEM:PlayerSpawnHook(client, isInitial)
    -- Called when a player holding this item spawns
    if isInitial then
        client:Notify("You have a " .. self:GetName())
    end
end

function ITEM:PlayerDeathHook(client, attacker, dmgInfo)
    -- Called when a player holding this item dies
    if self:GetDropOnDeath() == DEATH_DROP_ALWAYS then
        -- Custom drop logic
    end
end

Item Properties

Core Properties

-- Example: Basic properties
:SetName("Item Name")
:SetDescription("Item description")
:SetModel("models/path.mdl")
:SetCost(100)
:SetSize(2)
:SetCategory("Category")

-- Example: Advanced properties
:SetStackable(true)
:SetMaxStack(10) -- Max stack size
:SetStore(true) -- Available in store
:SetSkin(1) -- Model skin
:Untradable() -- Disable drop/give

Stackable Items

Stackable items can be combined into single inventory slots, trading flexibility for simplicity. When an item is stackable, multiple instances are merged into a single stack with a quantity counter.

Key Characteristics

  • No unique data: Stackable items cannot store individual data (SetData/GetData affects the entire stack)
  • Quantity-based: Items are represented by a number rather than individual instances
  • Space efficient: Multiple items occupy a single inventory slot
  • Batch operations: Can be split, merged, and transferred in specific quantities

Configuration

ITEM:New("base_item")
    :SetName("Health Potion")
    :SetStackable(true)    -- Enable stacking
    :SetMaxStack(5)        -- Maximum items per stack (defaults to batch value)
    :SetBatch(5)           -- Default batch size for store purchases
    :AutoRegister()

Legacy Configuration

-- Legacy format (still supported)
local ITEM = {}
ITEM.name = "Health Kit"
ITEM.stackable = true
ITEM.maxstack = 5  -- Maximum stack size
ITEM.batch = 5     -- Default batch amount

Stack Management Methods

-- Check if item is stackable
if item:Stackable() then
    -- Get current stack amount
    local amount = item:GetStack()

    -- Get maximum stack size
    local maxStack = item:GetMaxStack()

    -- Get available space in stack
    local freeSpace = item:GetFreeStackSpace()

    -- Split stack (server-side only)
    item:Split(2) -- Creates new stack with 2 items

    -- Transfer specific amount (server-side only)
    item:Transfer(targetInventory, 3) -- Transfer 3 items
end

Player Actions

Stackable items automatically support quantity-based actions:

  • Split: Divide stack into two separate stacks
  • Drop: Drop specific amount from stack
  • Give: Give specific amount to another player
  • Use: Consume single item from stack (typical behavior)

Lifecycle Hooks

-- Called when stacks are split
function ITEM:onSplit()
    -- Handle split behavior (if needed)
end

-- Called when stacks are merged
function ITEM:onMerge()
    -- Handle merge behavior (if needed)
end

Example: Consumable Stackable Item

--- @class health_potion : base_consumable
local ITEM = ITEM:New("base_consumable")
    :SetName("Health Potion")
    :SetDescription("Restores 50 health when consumed")
    :SetModel("models/props_junk/PopCan01a.mdl")
    :SetCost(100)
    :SetCategory("Medical")
    :SetStackable(true)
    :SetMaxStack(5)
    :SetBatch(5) --[[@as base_consumable]]
    :SetHealAmount(50) --[[@as base_consumable]]
    :AutoRegister()

Important Considerations

  1. Data Loss: Making an item stackable removes the ability to store unique data per item
  2. Batch vs MaxStack: batch sets default purchase quantity, maxstack sets stacking limit
  3. Database Impact: Stackable items are more efficient for storage and network sync
  4. Action Impact: All actions operate on individual items within the stack
  5. Split Behavior: onSplit and onMerge hooks provide custom split/merge logic if needed

Custom Properties

-- Define custom properties with auto-generated getters/setters
:AutoFunction("Durability", "durability")(100)
:AutoFunction("Rarity", "rarity")("common")
:AutoFunction("Enchantment", "enchantment")(nil)

-- Usage in code
local durability = item:GetDurability()
item:SetDurability(durability - 10)

Item Management

Creating Items

-- Create item in inventory
cityrp.item.Create(inventory, uniqueID, creatorID, stack, noSync, callback, ...)

-- Examples
local inv = client:GetInventory()
cityrp.item.Create(inv, "health_potion", client:SteamID64(), 1, false, function(item, inventory)
    client:Notify("Created " .. item:GetName())
end)

-- Create multiple items
cityrp.item.Create(inv, "ammo_pistol", client:SteamID64(), 50)

Getting Items

-- Get item by ID
local item = cityrp.item.Get(itemID)

-- Get item base
local base = cityrp.item.GetBase("health_potion")

-- Get all items of type from inventory
local items = inventory:GetItems("health_potion")

-- Get item count
local count = inventory:GetItemCount("health_potion")

Item Actions

-- Perform item action
item:PlayerAction(client, "Use", ...)

-- Check if action is available
local canUse, reason = item:CanPlayerAction(client, "Use")
if not canUse then
    client:Notify(reason)
end

-- Get available actions
local actions = item:GetActions()
for actionID, action in pairs(actions) do
    print(actionID, action.name)
end

Equipment System

Equipment Slots

-- Define equipment slot
:SetSlot("helmet") -- Equipment slot name

-- Slot management
local equippedItem = client:GetEquippedItem("helmet")
client:EquipItem(item, "helmet")
client:UnequipItem("helmet")

Drop on Death Behavior

-- Drop behavior constants
DEATH_DROP_NEVER     -- Never drop on death
DEATH_DROP_SOMETIMES -- Drop based on circumstances
DEATH_DROP_ALWAYS    -- Always drop on death

-- Set drop behavior
:SetDropOnDeath(DEATH_DROP_SOMETIMES)

Equipment Actions (Examples)

-- Equipment-specific actions
local function EquipCondition(baseItem, item, client)
    local currentItem = client:GetEquippedItem(item:GetSlot())
    if IsValid(currentItem) then
        return false, "Already have " .. item:GetSlot() .. " equipped"
    end
    return true
end

local function EquipAction(baseItem, item, client)
    client:EquipItem(item)
    client:Notify("Equipped " .. item:GetName())
    return false -- Don't consume item
end

ITEM:AddAction("Equip")
    :SetCondition(EquipCondition)
    :SetAction(EquipAction)
    :Build()

Advanced Patterns

Conditional Actions (Examples)

-- Action that changes based on context
local function ContextualCondition(baseItem, item, client, ...)
    if client:InVehicle() then
        return false, "Cannot use while in vehicle"
    end

    if client:GetMoveType() == MOVETYPE_NOCLIP then
        return false, "Cannot use in noclip"
    end

    -- Check for specific job
    if item:GetRequiredJob() and client:Team() != item:GetRequiredJob() then
        return false, "Wrong job to use this item"
    end

    return true
end

Data-Driven Items (Examples)

-- Items that change behavior based on data
function ITEM:ItemCreated(item)
    -- Set random properties
    item:SetData("quality", math.random(1, 5))
    item:SetData("enchantment", table.Random({"fire", "ice", "lightning"}))
end

local function EnchantedUseAction(baseItem, item, client)
    local enchantment = item:GetData("enchantment")
    local quality = item:GetData("quality", 1)

    if enchantment == "fire" then
        -- Fire effect
        local damage = 10 * quality
        client:TakeDamage(damage, client, item)
    elseif enchantment == "ice" then
        -- Ice effect
        client:Freeze(quality)
    end

    return true
end

Item Combinations (Examples)

-- Items that can be combined
local function CombineCondition(baseItem, item, client, targetItem)
    if not IsValid(targetItem) then
        return false, "Must target another item"
    end

    if targetItem:GetUniqueID() != "base_component" then
        return false, "Can only combine with components"
    end

    return true
end

local function CombineAction(baseItem, item, client, targetItem)
    -- Create new item from combination
    local inv = item:GetInventory()
    cityrp.item.Create(inv, "upgraded_item", client:SteamID64(), 1, false, function(newItem)
        client:Notify("Created " .. newItem:GetName())
    end)

    -- Remove source items
    item:Remove()
    targetItem:Remove()

    return false -- Already handled removal
end

ITEM:AddAction("Combine")
    :SetCondition(CombineCondition)
    :SetAction(CombineAction)
    :Build()

Migration from Legacy System

Legacy Compatibility

The system provides automatic conversion for legacy items:

-- Legacy item (automatically converted)
ITEM.name = "Old Item"
ITEM.onUse = function(item, client)
    -- Legacy use function
    return true
end

-- Converted to modern action automatically
-- No changes needed for existing legacy items

Modernizing Legacy Items

-- Convert legacy item to modern pattern
-- OLD:
ITEM.onUse = function(item, client)
    client:Notify("Used item")
    return true
end

-- NEW:
local function UseAction(baseItem, item, client)
    client:Notify("Used item")
    return true
end

ITEM:AddAction("Use")
    :SetAction(UseAction)
    :Build()

Best Practices

Performance

  • Use :DisableAutoActions() for bases to prevent unnecessary conversion
  • Cache frequently accessed data in item properties
  • Use lifecycle hooks efficiently
  • Batch item operations when possible

Code Organization

  • Keep bases generic and reusable
  • Use meaningful base names (base_weapon, not base_item2)
  • Document custom properties with LuaLS annotations
  • Separate complex logic into helper functions

Error Handling

local function SafeUseAction(baseItem, item, client)
    if not IsValid(client) or not IsValid(item) then
        return false
    end

    local success, err = pcall(function()
        -- Action logic here
        client:Notify("Used " .. item:GetName())
    end)

    if not success then
        ErrorNoHalt("Item use error: " .. err)
        return false
    end

    return true
end

Testing

-- Debug command for testing items
concommand.Add("give_item", function(client, cmd, args)
    if not client:IsAdmin() then return end

    local uniqueID = args[1]
    local amount = tonumber(args[2]) or 1

    if not cityrp.item.GetBase(uniqueID) then
        client:Notify("Invalid item: " .. uniqueID)
        return
    end

    local inv = client:GetInventory()
    cityrp.item.Create(inv, uniqueID, client:SteamID64(), amount)
end)