Why Crafting Systems Drive Player Engagement
A crafting system gives players a reason to gather resources, explore the map, interact with other players for trade, and invest time into building something tangible. Without crafting, items either come from shops with infinite stock or admin spawns, both of which flatten the economy and remove player agency. A well-designed crafting system creates supply chains where one player mines raw materials, another refines them, and a third assembles the final product. This interdependence generates organic roleplay interactions and economic activity. Weapon crafting adds a layer of risk and reward to the criminal underworld, while legal crafting paths like cooking, tailoring, or electronics repair provide legitimate income streams. In this guide, we will build a complete crafting framework with recipes, workbenches, skill progression, and blueprint discovery.
Designing the Recipe System
Recipes are the core data structure of any crafting system. Each recipe defines what items are required as inputs, what item is produced as output, how long the crafting process takes, what skill level is needed, and optionally what workbench type is required. Store recipes in a shared configuration file that both the client and server can access, since the client needs recipe data to display the crafting UI while the server needs it to validate crafting attempts. Use a flat table indexed by a unique recipe ID for fast lookups. Include a category field to organize recipes in the UI and a successChance field that can be modified by the player's skill level, adding a risk element to high-tier crafting.
-- shared/config.lua (accessible by both client and server)
Config = {}
Config.Recipes = {
-- Weapons
pistol_craft = {
label = 'Craft Pistol',
category = 'weapons',
result = {item = 'weapon_pistol', count = 1},
ingredients = {
{item = 'steel_plate', count = 3},
{item = 'weapon_spring', count = 2},
{item = 'rubber_grip', count = 1},
{item = 'gun_oil', count = 1},
},
duration = 15000, -- 15 seconds
skillReq = 50, -- crafting skill level
workbench = 'weapons_bench',
successBase = 0.75, -- 75% base success
xpReward = 25,
},
-- Electronics
radio_craft = {
label = 'Assemble Radio',
category = 'electronics',
result = {item = 'radio', count = 1},
ingredients = {
{item = 'electronic_parts', count = 2},
{item = 'copper_wire', count = 3},
{item = 'plastic_casing', count = 1},
},
duration = 8000,
skillReq = 10,
workbench = 'electronics_bench',
successBase = 0.90,
xpReward = 10,
},
-- Cooking
sandwich_craft = {
label = 'Make Sandwich',
category = 'cooking',
result = {item = 'sandwich', count = 2},
ingredients = {
{item = 'bread', count = 2},
{item = 'cheese', count = 1},
{item = 'lettuce', count = 1},
},
duration = 3000,
skillReq = 0,
workbench = 'kitchen',
successBase = 1.0,
xpReward = 3,
},
}
Config.Workbenches = {
weapons_bench = {
label = 'Weapons Workbench',
model = 'prop_tool_bench02',
coords = {
{x = 1083.5, y = -1975.3, z = 31.5, h = 140.0},
},
},
electronics_bench = {
label = 'Electronics Station',
model = 'prop_cs_server_drive',
coords = {
{x = 732.1, y = -1073.8, z = 22.2, h = 90.0},
},
},
kitchen = {
label = 'Kitchen',
model = 'prop_cooker_03',
coords = {
{x = -1192.3, y = -896.1, z = 13.9, h = 35.0},
},
},
}
Server-Side Crafting Logic
All crafting validation must happen on the server to prevent exploitation. The server checks that the player has the required ingredients in their inventory, meets the skill requirement, is near the correct workbench type, and is not already in a crafting process. After validation, it removes the ingredients, runs the duration timer, calculates success based on the base chance modified by skill level, and either grants the crafted item or returns partial materials on failure. Never trust the client to report crafting results. The client should only send a request to craft a specific recipe ID, and the server independently verifies every condition. This prevents item duplication exploits where a modified client claims to have crafted items without consuming ingredients.
-- server.lua
local craftingPlayers = {} -- track who is currently crafting
local playerSkills = {} -- cache of player crafting XP
RegisterNetEvent('crafting:attempt', function(recipeId)
local src = source
if craftingPlayers[src] then
TriggerClientEvent('notifications:show', src, 'Error', 'Already crafting.', 'error')
return
end
local recipe = Config.Recipes[recipeId]
if not recipe then return end
-- Check skill requirement
local skill = GetPlayerCraftingSkill(src)
if skill < recipe.skillReq then
TriggerClientEvent('notifications:show', src,
'Error', 'Requires crafting level ' .. recipe.skillReq .. '.', 'error')
return
end
-- Check ingredients
for _, ing in ipairs(recipe.ingredients) do
local count = exports.ox_inventory:GetItemCount(src, ing.item)
if count < ing.count then
TriggerClientEvent('notifications:show', src,
'Error', 'Missing materials.', 'error')
return
end
end
-- Remove ingredients
for _, ing in ipairs(recipe.ingredients) do
exports.ox_inventory:RemoveItem(src, ing.item, ing.count)
end
craftingPlayers[src] = true
TriggerClientEvent('crafting:startProgress', src, recipe.duration, recipe.label)
-- Wait for crafting duration
SetTimeout(recipe.duration, function()
craftingPlayers[src] = nil
-- Calculate success chance (skill bonus caps at +20%)
local skillBonus = math.min((skill - recipe.skillReq) * 0.005, 0.20)
local finalChance = math.min(recipe.successBase + skillBonus, 1.0)
if math.random() <= finalChance then
exports.ox_inventory:AddItem(src, recipe.result.item, recipe.result.count)
AddCraftingXP(src, recipe.xpReward)
TriggerClientEvent('notifications:show', src,
'Success', 'Crafted: ' .. recipe.label, 'success')
else
-- Return 50% of materials on failure
for _, ing in ipairs(recipe.ingredients) do
local refund = math.floor(ing.count * 0.5)
if refund > 0 then
exports.ox_inventory:AddItem(src, ing.item, refund)
end
end
TriggerClientEvent('notifications:show', src,
'Failed', 'Crafting failed. Some materials recovered.', 'error')
end
end)
end)
Skill Progression and Experience
A skill progression system adds long-term goals and a sense of advancement for players who invest in crafting. Each successful craft awards experience points that accumulate toward skill levels. Higher skill levels unlock advanced recipes and increase the success rate of difficult crafts. Store skill data per character in the database so it persists and is tied to the character rather than the player account. Define clear milestones: level 0-10 allows basic cooking and repairs, level 11-30 opens electronics and basic tools, level 31-60 enables weapon components and advanced manufacturing, and level 61-100 unlocks rare and legendary recipes. Display the current skill level and progress bar in the crafting UI so players always see how close they are to the next unlock.
-- Skill system (server.lua continued)
local skillCache = {}
function GetPlayerCraftingSkill(src)
if skillCache[src] then return skillCache[src].level end
return 0
end
function AddCraftingXP(src, amount)
if not skillCache[src] then
skillCache[src] = {xp = 0, level = 0}
end
skillCache[src].xp = skillCache[src].xp + amount
local newLevel = CalculateLevel(skillCache[src].xp)
if newLevel > skillCache[src].level then
skillCache[src].level = newLevel
TriggerClientEvent('notifications:show', src,
'Level Up!', 'Crafting level: ' .. newLevel, 'success', 6000)
-- Check for newly unlocked recipes
local unlocked = GetNewlyUnlockedRecipes(newLevel, skillCache[src].level)
for _, recipe in ipairs(unlocked) do
TriggerClientEvent('notifications:show', src,
'Recipe Unlocked', recipe.label .. ' is now available.', 'info', 5000)
end
end
skillCache[src].level = newLevel
SaveCraftingSkill(src)
end
function CalculateLevel(xp)
-- Each level requires progressively more XP
-- Level 1 = 50xp, Level 2 = 150xp, Level 10 = 2750xp, etc.
local level = 0
local required = 50
local remaining = xp
while remaining >= required and level < 100 do
remaining = remaining - required
level = level + 1
required = math.floor(required * 1.15)
end
return level
end
function SaveCraftingSkill(src)
local charId = exports['multichar']:GetCharacterId(src)
if not charId or not skillCache[src] then return end
MySQL.update(
'INSERT INTO character_skills (character_id, skill, xp, level) VALUES (?, ?, ?, ?) ON DUPLICATE KEY UPDATE xp = ?, level = ?',
{charId, 'crafting', skillCache[src].xp, skillCache[src].level, skillCache[src].xp, skillCache[src].level}
)
end
Workbench Interaction and Client UI
Workbenches are physical locations in the game world where players go to craft items. Each workbench type supports a specific category of recipes: a weapons bench for firearms, a kitchen for food, an electronics station for gadgets. On the client side, spawn a marker or use a target system like ox_target to create an interaction point at each workbench location. When the player interacts, check their proximity, filter the available recipes based on the workbench type and the player's skill level, and open the crafting UI. The UI should display each recipe's name, required ingredients with current inventory counts, the success probability, crafting time, and skill XP reward. Highlight recipes where the player has all required materials and dim those where materials are missing. During crafting, play an appropriate animation, show a progress bar, and disable player movement to prevent exploits where players walk away mid-craft.
-- client.lua
local isCrafting = false
-- Setup workbench interaction points
CreateThread(function()
for benchType, bench in pairs(Config.Workbenches) do
for _, loc in ipairs(bench.coords) do
exports.ox_target:addSphereZone({
coords = vector3(loc.x, loc.y, loc.z),
radius = 1.5,
options = {
{
label = 'Use ' .. bench.label,
icon = 'fas fa-tools',
onSelect = function()
OpenCraftingMenu(benchType)
end,
canInteract = function()
return not isCrafting
end
}
}
})
end
end
end)
function OpenCraftingMenu(benchType)
local recipes = {}
for id, recipe in pairs(Config.Recipes) do
if recipe.workbench == benchType then
recipes[#recipes + 1] = {
id = id,
label = recipe.label,
category = recipe.category,
ingredients = recipe.ingredients,
duration = recipe.duration,
skillReq = recipe.skillReq,
successBase = recipe.successBase,
xpReward = recipe.xpReward,
result = recipe.result,
}
end
end
-- Send to NUI or use a menu library
SendNUIMessage({action = 'openCrafting', recipes = recipes, benchType = benchType})
SetNuiFocus(true, true)
end
RegisterNetEvent('crafting:startProgress', function(duration, label)
isCrafting = true
local ped = PlayerPedId()
FreezeEntityPosition(ped, true)
-- Play crafting animation
RequestAnimDict('mini@repair')
while not HasAnimDictLoaded('mini@repair') do Wait(10) end
TaskPlayAnim(ped, 'mini@repair', 'fixing_a_player', 8.0, -8.0, -1, 1, 0, false, false, false)
-- Progress bar via ox_lib or custom NUI
exports.ox_lib:progressBar({
duration = duration,
label = label,
useWhileDead = false,
canCancel = false,
disable = {move = true, car = true, combat = true},
})
ClearPedTasks(ped)
FreezeEntityPosition(ped, false)
isCrafting = false
end)
Production Chains and Material Gathering
Production chains create depth by requiring players to process raw materials through multiple stages before crafting the final product. Iron ore must be smelted into steel ingots at a furnace, which are then pressed into steel plates at a metalworks, which are finally used at a weapons bench to craft firearms. Each step requires a different workbench location and potentially a different skill. This chain design encourages specialization, where some players focus on mining and smelting while others specialize in assembly. Material gathering points should be spread across the map using zones where players can perform gathering actions like mining rock nodes, harvesting plants, or scavenging electronic components from junkyards. Use a respawn timer on gathering nodes so resources are limited and players compete over access, driving territory-based roleplay conflicts.
Blueprint Discovery and Rare Recipes
Not all recipes should be available from the start. Blueprint discovery adds an exploration and progression incentive by hiding advanced recipes behind loot drops, quest completions, NPC purchases, or random world events. When a player discovers a blueprint, it is added to their personal recipe list stored in the database. Blueprints can be tradeable items, creating a secondary market where rare recipes become valuable commodities. For example, a military-grade weapon blueprint might only drop from certain heist rewards, while a legendary cooking recipe could be purchased from a hidden NPC that only appears at specific times. The crafting UI checks the player's unlocked blueprints against the full recipe list and only shows recipes they have access to, creating a sense of progression where each new blueprint feels like a meaningful unlock that expands their crafting capabilities.
-- Blueprint system (server.lua)
RegisterNetEvent('crafting:useBlueprint', function(blueprintItem)
local src = source
local charId = exports['multichar']:GetCharacterId(src)
if not charId then return end
-- Validate the player has the blueprint item
local count = exports.ox_inventory:GetItemCount(src, blueprintItem)
if count < 1 then return end
-- Map blueprint items to recipe IDs
local blueprintMap = {
blueprint_pistol = 'pistol_craft',
blueprint_radio = 'radio_craft',
blueprint_armor = 'armor_craft',
blueprint_lockpick = 'adv_lockpick_craft',
}
local recipeId = blueprintMap[blueprintItem]
if not recipeId then return end
-- Check if already unlocked
local exists = MySQL.scalar.await(
'SELECT 1 FROM character_blueprints WHERE character_id = ? AND recipe_id = ?',
{charId, recipeId}
)
if exists then
TriggerClientEvent('notifications:show', src,
'Info', 'You already know this recipe.', 'info')
return
end
-- Consume blueprint and unlock recipe
exports.ox_inventory:RemoveItem(src, blueprintItem, 1)
MySQL.insert(
'INSERT INTO character_blueprints (character_id, recipe_id) VALUES (?, ?)',
{charId, recipeId}
)
local recipe = Config.Recipes[recipeId]
TriggerClientEvent('notifications:show', src,
'Blueprint Learned', 'You can now craft: ' .. (recipe and recipe.label or recipeId), 'success', 6000)
end)