Why Inventory Architecture Matters
The inventory system is the backbone of any FiveM roleplay server. Every player interaction with items, from picking up a weapon to handing someone a key, flows through the inventory. A poorly designed inventory leads to item duplication exploits, desync issues, and frustrated players who lose their gear. The most robust inventory systems follow a server-authoritative model where the client only displays what the server tells it, never trusting the client to report its own state. This architectural decision alone eliminates the majority of duplication exploits that plague servers with client-trusted inventories. When planning your inventory, think about the data flow first: the server owns the truth, the client renders it, and every mutation goes through a validated server event.
Weight vs Slot-Based Systems
Choosing between weight-based and slot-based inventory systems fundamentally shapes the player experience. In a slot-based system, each slot holds one item type with a maximum stack size, and the total number of slots defines carrying capacity. In a weight-based system, every item has a weight value, and the player has a maximum carry weight. Many modern frameworks combine both approaches, using slots for organization but limiting total capacity by weight. Here is an example of a combined weight-and-slot item definition:
-- Shared item definitions (items.lua)
QBCore.Shared.Items = {
['water_bottle'] = {
name = 'water_bottle',
label = 'Water Bottle',
weight = 500, -- grams
type = 'item',
image = 'water_bottle.png',
unique = false,
useable = true,
shouldClose = true,
description = 'A refreshing bottle of water',
stackSize = 10, -- max per slot
},
['lockpick'] = {
name = 'lockpick',
label = 'Lockpick',
weight = 200,
type = 'item',
image = 'lockpick.png',
unique = false,
useable = true,
shouldClose = true,
description = 'Used to pick locks',
stackSize = 5,
},
}
The weight field is stored in grams for precision, and the stackSize controls how many of that item fit in a single slot. When a player tries to pick up an item, validate both that a slot is available and that the total weight would not exceed the maximum. This dual validation prevents players from carrying unrealistic amounts of heavy items even if they have empty slots.
Server-Side Validation and Anti-Exploit
Every inventory action must be validated on the server before it takes effect. When a player drags an item from slot 3 to slot 7, the client sends a move request, and the server verifies that the source slot actually contains that item, the destination slot can accept it, and the quantities are consistent. Never let the client specify item counts or create items from nothing. Here is a secure server-side move handler:
RegisterNetEvent('inventory:server:moveItem', function(fromSlot, toSlot, fromAmount)
local src = source
local Player = QBCore.Functions.GetPlayer(src)
if not Player then return end
local fromItem = Player.PlayerData.items[fromSlot]
if not fromItem then
-- Source slot is empty, possible exploit attempt
DropPlayer(src, 'Invalid inventory operation')
return
end
if fromAmount > fromItem.amount or fromAmount < 1 then
DropPlayer(src, 'Invalid inventory amount')
return
end
local toItem = Player.PlayerData.items[toSlot]
if toItem and toItem.name == fromItem.name and not fromItem.unique then
-- Stack items together
local maxStack = QBCore.Shared.Items[fromItem.name].stackSize or 50
local canStack = maxStack - toItem.amount
local moveAmount = math.min(fromAmount, canStack)
if moveAmount > 0 then
toItem.amount = toItem.amount + moveAmount
fromItem.amount = fromItem.amount - moveAmount
if fromItem.amount <= 0 then
Player.PlayerData.items[fromSlot] = nil
end
end
else
-- Swap items between slots
Player.PlayerData.items[toSlot] = fromItem
Player.PlayerData.items[fromSlot] = toItem
end
Player.Functions.SetPlayerData('items', Player.PlayerData.items)
end)
Notice the DropPlayer calls for clearly impossible operations. Logging these events to a separate audit table helps you identify exploit attempts and patterns. Consider implementing rate limiting on inventory events as well, because legitimate players rarely perform more than a few inventory operations per second, while automated exploits often send hundreds of requests rapidly.
Item Metadata and Unique Items
Metadata transforms simple items into rich, unique objects. A weapon can carry its serial number, durability, and attached modifications. A phone can store its assigned number and contact list reference. Food items can have an expiration timestamp. Metadata is stored as a Lua table serialized to JSON in the database, attached to each item instance. The key distinction is between stackable items, which share the same metadata, and unique items, which each get their own metadata table. Here is how to create a weapon with full metadata:
-- Creating a weapon with metadata
function CreateWeaponItem(src, weaponName, serial)
local Player = QBCore.Functions.GetPlayer(src)
if not Player then return false end
local metadata = {
serial = serial or GenerateSerial(),
durability = 100.0,
ammo = 0,
attachments = {},
registered = false,
registeredTo = nil,
quality = math.random(85, 100),
created = os.time(),
}
return Player.Functions.AddItem(weaponName, 1, nil, metadata)
end
function GenerateSerial()
local chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
local serial = ''
for i = 1, 10 do
local idx = math.random(1, #chars)
serial = serial .. chars:sub(idx, idx)
end
return serial
end
When displaying items with metadata in the NUI, pass the metadata alongside the item info so the UI can show details like durability bars, serial numbers, and quality ratings. This gives players a deeper connection to their items and supports advanced roleplay scenarios like weapon registration systems or forensic investigations tracing a serial number back to its owner.
Drag-and-Drop NUI Performance
The inventory UI is one of the most performance-sensitive NUI elements because players interact with it constantly. Avoid re-rendering the entire inventory grid on every update. Instead, use a virtual DOM approach or targeted element updates that only modify the slots that changed. When a player drags an item, handle the drag entirely in JavaScript using mouse events rather than sending position updates to the Lua client during the drag. Only send the final drop result as a single NUI callback. Here is a performant drag handler pattern:
// Inventory NUI - performant drag and drop
let draggedItem = null;
let dragElement = null;
document.addEventListener('mousedown', (e) => {
const slot = e.target.closest('.inv-slot[data-has-item="true"]');
if (!slot) return;
draggedItem = {
slot: parseInt(slot.dataset.slot),
item: JSON.parse(slot.dataset.itemInfo),
};
dragElement = slot.cloneNode(true);
dragElement.classList.add('dragging-ghost');
dragElement.style.position = 'fixed';
dragElement.style.pointerEvents = 'none';
dragElement.style.zIndex = '9999';
document.body.appendChild(dragElement);
moveDragElement(e.clientX, e.clientY);
});
document.addEventListener('mousemove', (e) => {
if (!dragElement) return;
moveDragElement(e.clientX, e.clientY);
});
document.addEventListener('mouseup', (e) => {
if (!draggedItem) return;
const targetSlot = e.target.closest('.inv-slot');
if (targetSlot) {
const toSlot = parseInt(targetSlot.dataset.slot);
// Send only the final result to Lua
fetch(`https://${GetParentResourceName()}/moveItem`, {
method: 'POST',
body: JSON.stringify({
fromSlot: draggedItem.slot,
toSlot: toSlot,
amount: draggedItem.item.amount,
}),
});
}
if (dragElement) dragElement.remove();
draggedItem = null;
dragElement = null;
});
For the visual rendering, use CSS Grid for the slot layout and avoid heavy CSS animations on inventory items since players may have dozens of slots visible simultaneously. Image sprites for item icons load faster than individual image files and reduce HTTP requests when the NUI first opens.
Stash and Container Systems
Beyond personal inventory, players need access to external storage like vehicle trunks, house stashes, and shared organization storage. Each container type should have its own capacity limits and access control rules. Vehicle trunks use the vehicle plate as a unique identifier, house stashes use property IDs, and job stashes use the job name combined with a grade check. Store container inventories in a dedicated database table separate from player inventories to keep queries efficient:
CREATE TABLE IF NOT EXISTS stash_items (
id INT AUTO_INCREMENT PRIMARY KEY,
stash_id VARCHAR(100) NOT NULL,
slot INT NOT NULL,
item_name VARCHAR(50) NOT NULL,
amount INT DEFAULT 1,
metadata LONGTEXT DEFAULT '{}',
UNIQUE KEY unique_stash_slot (stash_id, slot),
INDEX idx_stash_id (stash_id)
);
-- Example stash_id values:
-- 'trunk_ABC123' (vehicle trunk by plate)
-- 'house_42' (house stash by property id)
-- 'police_evidence_1' (job stash with identifier)
When a player opens a container, load its contents from the database and lock it to prevent simultaneous access by multiple players. Use a server-side lock table that tracks which stash IDs are currently open and by whom. Release the lock when the player closes the container or disconnects. This prevents the classic duplication exploit where two players open the same trunk and both withdraw the same items.
Inventory Synchronization and Persistence
Keeping inventory data synchronized between the server memory, the database, and the client display requires careful coordination. Save player inventory to the database periodically, not on every single item change, to reduce database write load. A save interval of 30 to 60 seconds works well for most servers. Additionally, always save on player disconnect and on server shutdown using the playerDropped event and a shutdown handler. Implement a dirty flag system that only writes to the database when the inventory has actually changed since the last save:
local inventoryDirty = {}
-- Mark inventory as needing save
function MarkDirty(citizenid)
inventoryDirty[citizenid] = true
end
-- Periodic save loop
CreateThread(function()
while true do
Wait(30000) -- 30 seconds
for citizenid, dirty in pairs(inventoryDirty) do
if dirty then
local Player = QBCore.Functions.GetPlayerByCitizenId(citizenid)
if Player then
SaveInventoryToDatabase(citizenid, Player.PlayerData.items)
end
inventoryDirty[citizenid] = nil
end
end
end
end)
AddEventHandler('playerDropped', function()
local src = source
local Player = QBCore.Functions.GetPlayer(src)
if Player then
local citizenid = Player.PlayerData.citizenid
if inventoryDirty[citizenid] then
SaveInventoryToDatabase(citizenid, Player.PlayerData.items)
inventoryDirty[citizenid] = nil
end
end
end)
For the client side, batch NUI updates so that multiple rapid inventory changes, such as receiving several items from a crafting operation, result in a single UI refresh rather than one per item. This eliminates the flickering effect that players see when items appear one at a time in their inventory grid.
Performance Monitoring and Optimization
Monitor your inventory system performance by tracking key metrics: average database save time, event processing rate, and NUI render frequency. Use the FiveM profiler to identify bottlenecks in your inventory callbacks. Common performance traps include iterating over all player inventories every frame for proximity-based features like ground item pickups, serializing large metadata objects unnecessarily, and leaving NUI frames active when the inventory is closed. For ground items, use a spatial grid system that only checks items within the player cell rather than scanning every dropped item on the server. Cache item definitions in a lookup table indexed by item name so that fetching item properties is an O(1) hash lookup rather than an O(n) array scan. Finally, consider implementing inventory pagination for containers with very large capacities, loading only the visible slots and fetching additional rows as the player scrolls, which keeps both database queries and NUI rendering lightweight even for warehouses with hundreds of slots.