Part 6: Game Systems
The biggest gameplay part — everything from combat to crafting.
1. Game Loop Design Patterns
Core Design ArchitectureYour game loop defines the entire player experience. Each genre has a canonical loop pattern. Choosing the right one is the first and most important architectural decision.
Round-Based (Lobby → Prep → Fight → Results → Repeat)
Used in: arena shooters, Tower Defense, hide-and-seek. A RoundService manages state transitions via a shared state machine. All players see the same phase at the same time. A RoundTimer enforces phase duration.
-- RoundService.server.luau
local RoundService = {}
local STATE = { LOBBY = "Lobby", PREP = "Prep", FIGHT = "Fight", RESULTS = "Results" }
local roundState = STATE.LOBBY
local roundTimer = 0
function RoundService.startRound()
roundState = STATE.PREP
roundTimer = 30
BroadcastState("Prep", 30)
task.wait(30)
roundState = STATE.FIGHT
roundTimer = 120
BroadcastState("Fight", 120)
end
function RoundService.getState()
return roundState
end
Open World (Persistent)
Players join and leave at any time. No round boundaries. Uses AutoSave intervals and Region loading. State persists in DataStore. Examples: MeepCity, Jailbreak.
Tycoon (Drippers / Walls / Upgrades / Rebirth)
Drippers spawn money at intervals. Walls cost money and unlock new drippers. Upgrades multiply income. Rebirth resets everything for a permanent multiplier.
-- Dripper logic
local function processDripper(dripper)
local rate = dripper:GetAttribute("Rate") -- money per second
local amount = dripper:GetAttribute("Amount")
while task.wait(1 / rate) do
local owner = dripper:GetAttribute("Owner")
grantMoney(owner, amount)
end
end
Obby (Checkpoints / Timer)
Linear or branching course. Checkpoints save progress. Timer tracks completion. Best time saved to leaderstats.
Battle Royale (Drop → Fight → Extract)
Players drop from a vehicle, loot, fight in a shrinking zone, and extract via a point. Last alive or first to extract wins.
| Genre | State Machine | Persistence | Player Join | Session Length |
|---|---|---|---|---|
| Round-Based | Yes (4-5 states) | Per round | Lobby only | 5-15 min |
| Open World | No | Full DataStore | Anytime | Variable |
| Tycoon | Partial | Plot save | Anytime | 30+ min |
| Obby | Minimal | Best time | Anytime | 2-10 min |
| Battle Royale | Yes (4 states) | Per match | Lobby only | 10-25 min |
2. Combat System: Damage & Health
Combat SystemHealth must be server-authoritative. Never trust the client's health value. Use either Humanoid.Health (with Humanoid:TakeDamage()) or a custom system with IntAttributes. Custom systems give you full control over damage formulas, resistances, and damage types.
Server-Owned Health
Store health as an IntAttribute or a NumberValue inside the character. Only the server modifies it. Clients read it for display via a RemoteEvent or BindToAttributeChange.
Damage Formula with Level Scaling
local function calculateDamage(attackerLevel, defenderLevel, baseDamage, weaponMultiplier)
local levelRatio = attackerLevel / math.max(defenderLevel, 1)
local levelScale = 1 + (levelRatio - 1) * 0.1
local raw = baseDamage * weaponMultiplier * levelScale
return math.max(1, math.round(raw))
end
PvP Validation
Never let the client send "I dealt X damage." Instead, the server detects hits via tool hits, raycasts, or region checks. Validate the attacker can damage the target (team check, cooldown, range).
-- Complete Damage System
local DamageSystem = {}
local DEBOUNCE = {}
function DamageSystem.applyDamage(attacker, target, damageData)
if not attacker or not target then return end
if attacker == target then return end
local char = target.Character
if not char then return end
local humanoid = char:FindFirstChildOfClass("Humanoid")
if not humanoid or humanoid.Health <= 0 then return end
-- Debounce
local key = attacker .. "_" .. target
if DEBOUNCE[key] and tick() - DEBOUNCE[key] < 0.1 then return end
DEBOUNCE[key] = tick()
-- PvP flag
if damageData.damageType == "PvP" then
local attackerTeam = attacker.Team
local targetTeam = target:FindFirstChild("Team") and target.Team
if attackerTeam and targetTeam and attackerTeam == targetTeam then return end
end
-- Compute damage
local finalDamage = calculateDamage(
damageData.attackerLevel or 1,
damageData.defenderLevel or 1,
damageData.baseDamage or 10,
damageData.multiplier or 1
)
humanoid:TakeDamage(finalDamage)
-- Knockback
if damageData.knockbackForce then
local root = char:FindFirstChild("HumanoidRootPart")
if root then
local direction = (root.Position - attacker.Character.HumanoidRootPart.Position).Unit
root:ApplyImpulse(direction * damageData.knockbackForce)
end
end
-- Hit effect
local hitEvent = ReplicatedStorage:FindFirstChild("HitEffect")
if hitEvent then hitEvent:FireAllClients(target, finalDamage) end
return finalDamage
end
return DamageSystem
3. Equipment & Inventory System
Inventory SystemAn inventory is a table of item instances with quantities. Items are defined in a ModuleScript and validated server-side. Equip/unequip uses Tool objects or custom character attachments.
Item Definition Module
-- ItemConfig.luau (ModuleScript)
local ItemConfig = {}
ItemConfig.Items = {
WoodenSword = {
id = "WoodenSword",
name = "Wooden Sword",
type = "Weapon",
damage = 15,
multiplier = 1.0,
category = "Melee",
stackable = false,
maxStack = 1,
icon = "rbxassetid://123456",
description = "A basic training sword."
},
HealthPotion = {
id = "HealthPotion",
name = "Health Potion",
type = "Consumable",
healAmount = 50,
category = "Potion",
stackable = true,
maxStack = 99,
icon = "rbxassetid://789012",
description = "Restores 50 HP."
},
IronOre = {
id = "IronOre",
name = "Iron Ore",
type = "Material",
category = "Crafting",
stackable = true,
maxStack = 999,
icon = "rbxassetid://345678",
description = "Used for smelting."
}
}
function ItemConfig.getItem(id)
return ItemConfig.Items[id]
end
function ItemConfig.getItemsByType(itemType)
local results = {}
for id, item in ItemConfig.Items do
if item.type == itemType then
table.insert(results, item)
end
end
return results
end
return ItemConfig
Inventory Module
-- InventoryManager.server.luau
local ItemConfig = require(script.Parent.ItemConfig)
local InventoryManager = {}
-- inventoryData[userId] = { items = { {itemId, quantity} } }
local inventoryData = {}
function InventoryManager.loadInventory(userId, savedData)
inventoryData[userId] = savedData or { items = {} }
end
function InventoryManager.addItem(userId, itemId, quantity)
quantity = quantity or 1
local def = ItemConfig.getItem(itemId)
if not def then return false, "Invalid item" end
local inv = inventoryData[userId]
if not inv then return false, "No inventory" end
if def.stackable then
for _, entry in inv.items do
if entry.itemId == itemId then
entry.quantity += quantity
return true, "Added to stack"
end
end
table.insert(inv.items, { itemId = itemId, quantity = quantity })
return true, "New stack created"
else
-- Non-stackable: check if already owned
for _, entry in inv.items do
if entry.itemId == itemId then
return false, "Already owned (unique item)"
end
end
table.insert(inv.items, { itemId = itemId, quantity = 1 })
return true, "Item added"
end
end
function InventoryManager.removeItem(userId, itemId, quantity)
quantity = quantity or 1
local inv = inventoryData[userId]
if not inv then return false end
for i, entry in inv.items do
if entry.itemId == itemId then
entry.quantity -= quantity
if entry.quantity <= 0 then
table.remove(inv.items, i)
end
return true
end
end
return false
end
function InventoryManager.getInventory(userId)
return inventoryData[userId]
end
-- Equip flow (server validates, then replicates)
function InventoryManager.equipItem(userId, itemId)
local inv = inventoryData[userId]
if not inv then return false end
for _, entry in inv.items do
if entry.itemId == itemId and entry.quantity > 0 then
-- Clone tool from ReplicatedStorage and parent to character
local def = ItemConfig.getItem(itemId)
if def and def.type == "Weapon" then
local player = Players:GetPlayerByUserId(userId)
if player and player.Character then
local toolTemplate = ReplicatedStorage.Tools:FindFirstChild(itemId)
if toolTemplate then
toolTemplate:Clone().Parent = player.Character
return true
end
end
end
end
end
return false
end
return InventoryManager
unequipItem(userId, itemId) function that removes the tool from the character's backpack and returns it to inventory. Use player.Backpack for unequipped tools.
4. NPCs & Pathfinding
AI PathfindingRoblox PathfindingService computes paths around obstacles. Humanoid:MoveTo() follows the path. Use path.Blocked event to recompute. Combine with a Finite State Machine (FSM) for realistic NPC behavior.
NPC FSM: Idle → Patrol → Chase → Attack → Dead
- Idle: NPC stands still, looks around. Transitions to Patrol after N seconds.
- Patrol: Walks between defined waypoints. Detects player via Magnitude check.
- Chase: Computes path to target player. Updates every 1-2 seconds.
- Attack: Within range, stops moving and deals damage.
- Dead: Plays death animation, removes after delay.
-- PatrolNPC.server.luau
local PathfindingService = game:GetService("PathfindingService")
local npc = script.Parent
local humanoid = npc:FindFirstChild("Humanoid")
local root = npc:FindFirstChild("HumanoidRootPart")
local WAYPOINTS = {
Vector3.new(10, 0, 10),
Vector3.new(50, 0, 10),
Vector3.new(50, 0, 50),
Vector3.new(10, 0, 50)
}
local currentWaypoint = 1
local state = "Patrol"
local path = nil
local pathConnections = {}
local function updatePath(destination)
if path then
path:Destroy()
for _, conn in pathConnections do conn:Disconnect() end
table.clear(pathConnections)
end
path = PathfindingService:CreatePath({
AgentRadius = 2,
AgentHeight = 5,
AgentCanJump = true,
WaypointSpacing = 3,
Costs = { Water = 5 }
})
local success, err = pcall(function()
path:ComputeAsync(root.Position, destination)
end)
if not success then return end
local waypoints = path:GetWaypoints()
if #waypoints > 0 then
humanoid:MoveTo(waypoints[#waypoints].Position)
end
local blockedConn = path.Blocked:Connect(function(blockedWaypointIndex)
if blockedWaypointIndex > 1 then
updatePath(destination)
end
end)
table.insert(pathConnections, blockedConn)
end
local function findNearestPlayer()
local closest, closestDist = nil, math.huge
for _, player in Players:GetPlayers() do
if player.Character and player.Character:FindFirstChild("HumanoidRootPart") then
local dist = (root.Position - player.Character.HumanoidRootPart.Position).Magnitude
if dist < closestDist then
closest = player
closestDist = dist
end
end
end
return closest, closestDist
end
-- Patrol behavior
while task.wait(0.5) do
if state == "Patrol" then
local target, dist = findNearestPlayer()
if target and dist < 40 then
state = "Chase"
else
local dest = WAYPOINTS[currentWaypoint]
updatePath(dest)
if humanoid.MoveToFinished:Wait() then
currentWaypoint = (currentWaypoint % #WAYPOINTS) + 1
task.wait(1)
end
end
elseif state == "Chase" then
local target, dist = findNearestPlayer()
if not target or dist > 60 then
state = "Patrol"
elseif dist < 8 then
state = "Attack"
else
updatePath(target.Character.HumanoidRootPart.Position)
end
elseif state == "Attack" then
-- Deal damage every 1.5s
local target, dist = findNearestPlayer()
if not target or dist > 10 then
state = "Chase"
else
humanoid:MoveTo(root.Position) -- stop moving
task.wait(0.5)
local dmgSystem = require(game.ServerScriptService.DamageSystem)
dmgSystem.applyDamage(npc, target, {
baseDamage = 10,
multiplier = 1,
damageType = "NPC"
})
task.wait(1)
end
end
end
5. NPC Spawning & Wave Systems
AI SpawningWave systems spawn enemies in groups with increasing difficulty. Use a WaveManager with SpawnPoints placed in the world. Each wave defines enemy types, count, and spawn interval.
Wave Spawner with Difficulty Ramp
-- WaveManager.server.luau
local WaveManager = {}
local SpawnService = game:GetService("SpawnService")
local enemiesFolder = workspace.Enemies
local WAVE_TEMPLATES = {
{ name = "Wave 1", enemies = { "Zombie" }, count = 3, spawnInterval = 2, difficulty = 1 },
{ name = "Wave 2", enemies = { "Zombie", "Skeleton" }, count = 5, spawnInterval = 1.5, difficulty = 1.2 },
{ name = "Wave 3", enemies = { "Skeleton", "DarkKnight" }, count = 7, spawnInterval = 1, difficulty = 1.5 },
{ name = "Wave 4", enemies = { "DarkKnight", "BossZombie" }, count = 10, spawnInterval = 1, difficulty = 2 },
{ name = "Wave 5", enemies = { "BossZombie", "DarkKnight", "Zombie" }, count = 12, spawnInterval = 0.8, difficulty = 3 },
}
local spawnPoints = {}
local currentWave = 0
local enemiesAlive = 0
local waveInProgress = false
function WaveManager.init()
-- Find all spawn points
for _, part in workspace.SpawnPoints:GetChildren() do
table.insert(spawnPoints, part)
end
end
function WaveManager.getRandomSpawnPoint()
return spawnPoints[math.random(1, #spawnPoints)]
end
function WaveManager.spawnEnemy(enemyType, difficulty)
local template = ReplicatedStorage.Enemies:FindFirstChild(enemyType)
if not template then return end
local spawnPt = WaveManager.getRandomSpawnPoint()
local enemy = template:Clone()
enemy.Parent = enemiesFolder
enemy:SetPrimaryPartCFrame(spawnPt.CFrame * CFrame.new(0, 3, 0))
-- Scale difficulty
local humanoid = enemy:FindFirstChild("Humanoid")
if humanoid then
local baseHealth = humanoid.MaxHealth
humanoid.MaxHealth = baseHealth * difficulty
humanoid.Health = humanoid.MaxHealth
end
-- Scale damage
enemy:SetAttribute("DamageMultiplier", difficulty)
enemiesAlive += 1
-- Track death
local humanoid = enemy:FindFirstChild("Humanoid")
if humanoid then
humanoid.Died:Connect(function()
enemiesAlive -= 1
if enemiesAlive <= 0 and waveInProgress then
WaveManager.onWaveCleared()
end
end)
end
end
function WaveManager.startWave()
currentWave += 1
if currentWave > #WAVE_TEMPLATES then
-- Victory condition
BroadcastMessage("All waves cleared! Victory!")
return
end
waveInProgress = true
local waveData = WAVE_TEMPLATES[currentWave]
BroadcastMessage("Wave " .. currentWave .. " incoming!")
-- Scale difficulty
local difficulty = waveData.difficulty + (currentWave - 1) * 0.1
-- Spawn enemies in intervals
for i = 1, waveData.count do
local enemyType = waveData.enemies[math.random(1, #waveData.enemies)]
WaveManager.spawnEnemy(enemyType, difficulty)
task.wait(waveData.spawnInterval)
end
end
function WaveManager.onWaveCleared()
waveInProgress = false
BroadcastMessage("Wave " .. currentWave .. " cleared! Intermission...")
task.wait(10)
WaveManager.startWave()
end
function WaveManager.getCurrentWave()
return currentWave
end
-- Intermission loop (waiting for players to prepare)
local function intermissionLoop()
while task.wait(1) do
if currentWave == 0 then
if #Players:GetPlayers() >= 1 then
task.wait(5)
WaveManager.startWave()
end
end
end
end
spawn(intermissionLoop)
return WaveManager
bossWave every 5 waves that spawns a single large boss with 10x health, unique attacks, and a loot drop on death. Use a separate Boss template with special behavior.
6. Progression & Leveling
Progression SystemThe XP formula 100 * level^1.5 gives a smooth curve — early levels are fast, later levels slow down. Award XP per action with XP grants. Update leaderstats on level-up.
XP Formula
local function xpForLevel(level)
return math.floor(100 * (level ^ 1.5))
end
local function levelFromXP(totalXP)
local level = 1
while xpForLevel(level + 1) <= totalXP do
level += 1
end
return level
end
Leveling System
-- LevelSystem.server.luau
local DataStoreManager = require(script.Parent.DataStoreManager)
local LevelSystem = {}
-- XP grants per action
local XP_GRANTS = {
KillEnemy = 25,
CompleteWave = 100,
CompleteQuest = 150,
CraftItem = 10,
GatherResource = 5,
WinRound = 200,
DailyLogin = 50
}
function LevelSystem.grantXP(player, action)
local xpAmount = XP_GRANTS[action]
if not xpAmount then return end
local userId = player.UserId
local data = DataStoreManager.getPlayerData(userId)
if not data then return end
-- Apply XP boost if player has gamepass
if player:FindFirstChild("XPBoost") then
xpAmount = xpAmount * 1.5
end
data.TotalXP += xpAmount
data.CurrentXP += xpAmount
local newLevel = levelFromXP(data.TotalXP)
if newLevel > data.Level then
-- Level up!
local oldLevel = data.Level
data.Level = newLevel
data.CurrentXP = 0
-- Update leaderstats
local ls = player:FindFirstChild("leaderstats")
if ls then
ls.Level.Value = newLevel
ls.XP.Value = 0
ls.MaxXP.Value = xpForLevel(newLevel + 1)
end
-- Visual effects
local levelUpEffect = ReplicatedStorage:FindFirstChild("LevelUpEffect")
if levelUpEffect then
levelUpEffect:FireClient(player, newLevel)
end
-- Unlock rewards
LevelSystem.checkLevelRewards(player, newLevel)
end
-- Update XP bar (client-side via RemoteEvent)
local xpUpdate = ReplicatedStorage:FindFirstChild("UpdateXPBar")
if xpUpdate then
local maxXP = xpForLevel(data.Level + 1)
xpUpdate:FireClient(player, data.CurrentXP, maxXP)
end
-- Save
DataStoreManager.savePlayerData(userId)
end
function LevelSystem.checkLevelRewards(player, level)
local rewards = {
[5] = { type = "Item", id = "HealthPotion", quantity = 5 },
[10] = { type = "Item", id = "IronSword", quantity = 1 },
[15] = { type = "Currency", id = "Coins", amount = 500 },
[20] = { type = "Title", id = "Veteran" },
}
local reward = rewards[level]
if reward then
if reward.type == "Item" then
local inv = require(script.Parent.InventoryManager)
inv.addItem(player.UserId, reward.id, reward.quantity)
elseif reward.type == "Currency" then
local eco = require(script.Parent.EconomySystem)
eco.grantCurrency(player, reward.id, reward.amount)
elseif reward.type == "Title" then
local titleData = DataStoreManager.getPlayerData(player.UserId)
table.insert(titleData.Titles or {}, reward.id)
end
local rewardEvent = ReplicatedStorage:FindFirstChild("RewardClaimed")
if rewardEvent then
rewardEvent:FireClient(player, reward)
end
end
end
return LevelSystem
7. Quests & Achievements
Progression QuestsQuests are defined in a table with objectives, rewards, and progress tracking. Use BindableEvents for progress updates. Achievements are one-time quests stored in a bitfield or table.
Quest Definition Table
-- QuestConfig.luau (ModuleScript)
local QuestConfig = {}
QuestConfig.Quests = {
FirstBlood = {
id = "FirstBlood",
name = "First Blood",
description = "Defeat your first enemy.",
type = "Kill",
targetCount = 1,
rewards = { Coins = 100, XP = 50 },
isAchievement = true
},
ZombieSlayer = {
id = "ZombieSlayer",
name = "Zombie Slayer",
description = "Defeat 50 zombies.",
type = "Kill",
targetEnemy = "Zombie",
targetCount = 50,
rewards = { Coins = 500, XP = 250, Item = "ZombieHelmet" },
isAchievement = false
},
WaveWarrior = {
id = "WaveWarrior",
name = "Wave Warrior",
description = "Complete 10 waves total.",
type = "Wave",
targetCount = 10,
rewards = { Coins = 1000, XP = 500 },
isAchievement = false
},
CraftMaster = {
id = "CraftMaster",
name = "Craft Master",
description = "Craft 25 items.",
type = "Craft",
targetCount = 25,
rewards = { Coins = 750, XP = 300 },
isAchievement = false
},
Explorer = {
id = "Explorer",
name = "Explorer",
description = "Visit all 5 zones.",
type = "Explore",
targetZones = { "Forest", "Desert", "Tundra", "Volcano", "Crystal" },
targetCount = 5,
rewards = { Coins = 2000, XP = 1000, Title = "Explorer" },
isAchievement = true
}
}
function QuestConfig.getQuest(id)
return QuestConfig.Quests[id]
end
function QuestConfig.getQuestsByType(questType)
local results = {}
for id, quest in QuestConfig.Quests do
if quest.type == questType then
table.insert(results, quest)
end
end
return results
end
return QuestConfig
Quest Manager
-- QuestManager.server.luau
local QuestConfig = require(script.Parent.QuestConfig)
local DataStoreManager = require(script.Parent.DataStoreManager)
local QuestManager = {}
-- questProgress[userId] = { activeQuests = { {questId, progress} }, completed = {questId = true} }
local questProgress = {}
-- Events that quests listen to
local QuestEvents = Instance.new("BindableEvent")
QuestEvents.Name = "QuestEvents"
QuestEvents.Parent = script
function QuestManager.init()
-- Connect to game events
game.ReplicatedStorage.Events.KillEnemy.Event:Connect(function(player, enemyType)
QuestManager.progressQuest(player, "Kill", { enemyType = enemyType })
end)
game.ReplicatedStorage.Events.WaveCompleted.Event:Connect(function(player)
QuestManager.progressQuest(player, "Wave")
end)
game.ReplicatedStorage.Events.ItemCrafted.Event:Connect(function(player)
QuestManager.progressQuest(player, "Craft")
end)
game.ReplicatedStorage.Events.ZoneEntered.Event:Connect(function(player, zoneName)
QuestManager.progressQuest(player, "Explore", { zoneName = zoneName })
end)
end
function QuestManager.loadQuests(userId, savedData)
questProgress[userId] = savedData or {
activeQuests = {},
completed = {}
}
end
function QuestManager.assignQuest(userId, questId)
local quest = QuestConfig.getQuest(questId)
if not quest then return false end
local progress = questProgress[userId]
if not progress then return false end
-- Check not already completed (for achievements)
if quest.isAchievement and progress.completed[questId] then
return false, "Already completed"
end
-- Check not already active
for _, q in progress.activeQuests do
if q.questId == questId then
return false, "Already active"
end
end
table.insert(progress.activeQuests, {
questId = questId,
progress = 0,
startTime = os.time()
})
QuestEvents:Fire("QuestAssigned", userId, questId)
return true
end
function QuestManager.progressQuest(player, questType, context)
context = context or {}
local userId = player.UserId
local progress = questProgress[userId]
if not progress then return end
for i, q in progress.activeQuests do
local quest = QuestConfig.getQuest(q.questId)
if not quest or quest.type ~= questType then continue end
-- Check additional conditions
if quest.targetEnemy and context.enemyType ~= quest.targetEnemy then
continue
end
if quest.targetZones and context.zoneName then
-- Zone tracking: add zone to visited set
q.visitedZones = q.visitedZones or {}
q.visitedZones[context.zoneName] = true
q.progress = 0
for _, zone in quest.targetZones do
if q.visitedZones[zone] then
q.progress += 1
end
end
else
q.progress += 1
end
-- Check completion
if q.progress >= quest.targetCount or
(quest.targetZones and q.progress >= #quest.targetZones) then
QuestManager.completeQuest(userId, q.questId)
else
QuestEvents:Fire("QuestProgress", userId, q.questId, q.progress, quest.targetCount)
end
end
end
function QuestManager.completeQuest(userId, questId)
local quest = QuestConfig.getQuest(questId)
if not quest then return end
local progress = questProgress[userId]
if not progress then return end
-- Mark as completed
progress.completed[questId] = true
-- Remove from active
for i, q in progress.activeQuests do
if q.questId == questId then
table.remove(progress.activeQuests, i)
break
end
end
-- Grant rewards
local player = Players:GetPlayerByUserId(userId)
if player and quest.rewards then
if quest.rewards.Coins then
local eco = require(script.Parent.EconomySystem)
eco.grantCurrency(player, "Coins", quest.rewards.Coins)
end
if quest.rewards.XP then
local levelSys = require(script.Parent.LevelSystem)
levelSys.grantXP(player, "CompleteQuest")
end
if quest.rewards.Item then
local inv = require(script.Parent.InventoryManager)
inv.addItem(userId, quest.rewards.Item, 1)
end
if quest.rewards.Title then
local data = DataStoreManager.getPlayerData(userId)
table.insert(data.Titles or {}, quest.rewards.Title)
end
end
QuestEvents:Fire("QuestCompleted", userId, questId)
end
return QuestManager
8. Drop Tables & Loot Systems
Loot RandomWeighted random drops use a cumulative weight approach. Each item has a weight (higher = more common). A pity system guarantees rare drops after N attempts. Dropped items are temporary Pickup objects with a despawn timer.
Loot Table Module
-- LootTable.luau (ModuleScript)
local LootTable = {}
local RARITY_COLORS = {
Common = Color3.fromRGB(180, 180, 180),
Uncommon = Color3.fromRGB(30, 255, 30),
Rare = Color3.fromRGB(30, 144, 255),
Epic = Color3.fromRGB(170, 0, 255),
Legendary = Color3.fromRGB(255, 170, 0)
}
LootTable.Tables = {
Zombie = {
drops = {
{ itemId = "Bone", weight = 50, quantity = {1, 3}, rarity = "Common" },
{ itemId = "RottenFlesh", weight = 30, quantity = {1, 2}, rarity = "Common" },
{ itemId = "HealthPotion", weight = 15, quantity = {1, 1}, rarity = "Uncommon" },
{ itemId = "ZombieHelmet", weight = 4, quantity = {1, 1}, rarity = "Rare" },
{ itemId = "UndeadRing", weight = 1, quantity = {1, 1}, rarity = "Epic" }
},
guaranteedDrops = {
{ itemId = "Bone", quantity = 1 }
},
goldDrop = { min = 5, max = 15 }
},
BossZombie = {
drops = {
{ itemId = "Bone", weight = 20, quantity = {5, 10}, rarity = "Common" },
{ itemId = "RottenFlesh", weight = 15, quantity = {3, 5}, rarity = "Common" },
{ itemId = "HealthPotion", weight = 20, quantity = {2, 3}, rarity = "Uncommon" },
{ itemId = "ZombieHelmet", weight = 15, quantity = {1, 1}, rarity = "Uncommon" },
{ itemId = "UndeadRing", weight = 10, quantity = {1, 1}, rarity = "Rare" },
{ itemId = "ZombieKingCrown", weight = 3, quantity = {1, 1}, rarity = "Legendary" }
},
guaranteedDrops = {
{ itemId = "Bone", quantity = 3 },
{ itemId = "RottenFlesh", quantity = 2 }
},
goldDrop = { min = 50, max = 150 }
}
}
-- Pity system: tracks consecutive kills without rare drop
local pityTracker = {}
function LootTable.roll(tableName, userId)
local tableData = LootTable.Tables[tableName]
if not tableData then return {} end
local results = {}
-- Guaranteed drops first
for _, gd in tableData.guaranteedDrops do
table.insert(results, { itemId = gd.itemId, quantity = gd.quantity })
end
-- Gold
local goldAmount = math.random(tableData.goldDrop.min, tableData.goldDrop.max)
table.insert(results, { itemId = "Gold", quantity = goldAmount })
-- Weighted random drops
local totalWeight = 0
for _, drop in tableData.drops do
local effectiveWeight = drop.weight
-- Pity: increase weight for rare+ items
if (drop.rarity == "Rare" or drop.rarity == "Epic" or drop.rarity == "Legendary") and userId then
local pityCount = pityTracker[userId] or 0
effectiveWeight = drop.weight * (1 + pityCount * 0.5)
end
totalWeight += effectiveWeight
end
local roll = math.random() * totalWeight
local cumulative = 0
for _, drop in tableData.drops do
local effectiveWeight = drop.weight
if (drop.rarity == "Rare" or drop.rarity == "Epic" or drop.rarity == "Legendary") and userId then
local pityCount = pityTracker[userId] or 0
effectiveWeight = drop.weight * (1 + pityCount * 0.5)
end
cumulative += effectiveWeight
if roll <= cumulative then
local qty = math.random(drop.quantity[1], drop.quantity[2])
table.insert(results, { itemId = drop.itemId, quantity = qty, rarity = drop.rarity })
-- Reset pity on rare+
if drop.rarity == "Rare" or drop.rarity == "Epic" or drop.rarity == "Legendary" then
if userId then pityTracker[userId] = 0 end
else
if userId then
pityTracker[userId] = (pityTracker[userId] or 0) + 1
end
end
break
end
end
return results
end
-- Spawn a Pickup item in the world
function LootTable.spawnPickup(position, itemId, quantity)
local pickupTemplate = ReplicatedStorage.Pickups:FindFirstChild(itemId)
if not pickupTemplate then
pickupTemplate = ReplicatedStorage.Pickups.GenericPickup:Clone()
pickupTemplate.Name = itemId
end
local pickup = pickupTemplate:Clone()
pickup:SetPrimaryPartCFrame(CFrame.new(position + Vector3.new(0, 2, 0)))
pickup.Parent = workspace.Pickups
-- Despawn timer (60s)
task.delay(60, function()
if pickup and pickup.Parent then
pickup:Destroy()
end
end)
-- Floating animation
local tween = game:GetService("TweenService"):Create(
pickup.PrimaryPart,
TweenInfo.new(1, Enum.EasingStyle.Sine, Enum.EasingDirection.InOut, -1, true),
{ Position = pickup.PrimaryPart.Position + Vector3.new(0, 0.5, 0) }
)
tween:Play()
end
return LootTable
math.random() with cumulative distribution. Implement a "loot beam" visual (a bright Part beam from pickup to sky) that color-codes by rarity.
9. Interaction System
System UXThree main interaction methods: ProximityPrompt (built-in, easiest), ContextMenuInteraction (right-click), and raycasting interact (custom, most control). ProximityPrompt is recommended for 90% of cases.
ProximityPrompt: Door Example
-- Door with ProximityPrompt
local door = script.Parent
local prompt = Instance.new("ProximityPrompt")
prompt.HoldDuration = 0.5
prompt.MaxActivationDistance = 8
prompt.KeyboardKeyCode = Enum.KeyCode.E
prompt.ActionText = "Open Door"
prompt.ObjectText = "Wooden Door"
prompt.Parent = door
local hinge = door:FindFirstChild("Hinge") or door:FindFirstChild("DoorHinge")
local isOpen = false
local tweenService = game:GetService("TweenService")
prompt.Triggered:Connect(function(player)
if isOpen then
-- Close
if hinge then
local tween = tweenService:Create(hinge, TweenInfo.new(0.5), { CFrame = door:GetPivot() })
tween:Play()
end
prompt.ActionText = "Open Door"
isOpen = false
-- Set CanCollide back
door.CanCollide = true
else
-- Open (rotate around hinge)
if hinge then
local openCFrame = door:GetPivot() * CFrame.Angles(0, math.rad(-90), 0)
local tween = tweenService:Create(hinge, TweenInfo.new(0.5), { CFrame = openCFrame })
tween:Play()
end
prompt.ActionText = "Close Door"
isOpen = true
door.CanCollide = false
end
end)
-- Conditionally hide prompt based on team/game state
local function updatePrompt()
-- Example: only show to alive players
for _, player in Players:GetPlayers() do
if player.Character and player.Character:FindFirstChild("Humanoid") then
local dist = (player.Character.HumanoidRootPart.Position - door.Position).Magnitude
prompt.Enabled = dist <= prompt.MaxActivationDistance
end
end
end
Raycasting Interaction (for guns/pointing)
-- Raycast interact (client fires Remote to server)
-- Server handler:
local function onInteract(player, targetPath)
local target = workspace:FindFirstChild(targetPath, true)
if not target then return end
local interactable = target:FindFirstChild("Interactable")
if not interactable then return end
-- Validate distance
local char = player.Character
if not char or not char:FindFirstChild("HumanoidRootPart") then return end
local dist = (char.HumanoidRootPart.Position - target.Position).Magnitude
if dist > 20 then return end
local interactScript = target:FindFirstChild("InteractHandler")
if interactScript then
interactScript:FindFirstChild("OnInteract"):Fire(player)
end
end
game.ReplicatedStorage.Remotes.PlayerInteract.OnServerEvent:Connect(onInteract)
10. Vehicle Systems
Vehicles PhysicsRoblox VehicleSeat gives basic throttle/steer. For custom vehicles, use BodyGyro + BodyPosition for hover cars, or SpringConstraint + WeldConstraints for suspension. Always sync seat occupancy and CFrame to owner for multiplayer.
Simple Car with VehicleSeat
-- Car setup (placed in Workspace)
local carModel = script.Parent
local seat = carModel:FindFirstChild("VehicleSeat")
local bodyPosition = carModel:FindFirstChild("BodyPosition")
local bodyGyro = carModel:FindFirstChild("BodyGyro")
-- Create BodyMover if not present
if not bodyPosition then
bodyPosition = Instance.new("BodyPosition")
bodyPosition.MaxForce = Vector3.new(0, 10000, 0) -- Only Y axis
bodyPosition.P = 1000
bodyPosition.D = 100
bodyPosition.Parent = carModel.PrimaryPart
end
if not bodyGyro then
bodyGyro = Instance.new("BodyGyro")
bodyGyro.MaxTorque = Vector3.new(10000, 10000, 10000)
bodyGyro.P = 10000
bodyGyro.D = 500
bodyGyro.Parent = carModel.PrimaryPart
end
-- Suspension using SpringConstraints on each wheel
local function setupSuspension(wheel, chassis)
local spring = Instance.new("SpringConstraint")
spring.Attachment0 = wheel:FindFirstChild("Attachment")
spring.Attachment1 = chassis:FindFirstChild("Attachment")
spring.Stiffness = 500
spring.Damping = 50
spring.FreeLength = 1.5
spring.MaxLength = 2
spring.MinLength = 0.5
spring.Parent = wheel
end
local wheels = {}
for _, child in carModel:GetChildren() do
if child.Name:lower():find("wheel") then
table.insert(wheels, child)
end
end
for _, wheel in wheels do
-- Add wheel-specific attachment if missing
if not wheel:FindFirstChild("Attachment") then
local att = Instance.new("Attachment")
att.Position = Vector3.new(0, -0.5, 0)
att.Parent = wheel
end
setupSuspension(wheel, carModel.PrimaryPart)
end
-- Drive logic (runs while occupied)
seat:GetPropertyChangedSignal("Occupant"):Connect(function()
if seat.Occupant then
-- Enter
local player = Players:GetPlayerFromCharacter(seat.Occupant.Parent)
if player then
-- Set owner for networking
carModel:SetNetworkOwner(player)
bodyPosition.Visible = false
bodyGyro.Visible = false
end
else
-- Exit
carModel:SetNetworkOwner(nil)
bodyPosition.Visible = false
bodyGyro.Visible = false
end
end)
-- Drive loop
spawn(function()
while task.wait(0.03) do
if seat.Occupant and seat.Throttle ~= 0 then
local root = carModel.PrimaryPart
local force = root.CFrame.LookVector * seat.Throttle * 5000
root:ApplyImpulse(force * (1/30))
end
if seat.Occupant and seat.Steer ~= 0 then
bodyGyro.CFrame = carModel.PrimaryPart.CFrame * CFrame.Angles(0, seat.Steer * 0.02, 0)
end
-- Keep car upright
if seat.Occupant then
bodyGyro.CFrame = CFrame.new(carModel.PrimaryPart.Position, carModel.PrimaryPart.Position + carModel.PrimaryPart.CFrame.LookVector)
end
end
end)
11. Building & Tycoon Systems
Building TycoonGrid-snapping placement with ghost preview is the core of any building system. Server validates every placement (cost, space, adjacency). Tycoon-specific: droppers generate income, walls block paths, rebirth resets plot for multiplier.
Grid Placement
-- GridManager.server.luau
local GRID_SIZE = 4
local PLOT_SIZE = 20 -- 20x20 grid
-- plotData[userId] = { buildings = {{type, x, z, id}}, grid = 2D array }
local plotData = {}
function GridManager.initPlot(userId)
plotData[userId] = {
buildings = {},
grid = {},
rebirthCount = 0,
incomeMultiplier = 1
}
-- Initialize empty grid
for x = 1, PLOT_SIZE do
plotData[userId].grid[x] = {}
for z = 1, PLOT_SIZE do
plotData[userId].grid[x][z] = nil
end
end
end
function GridManager.canPlace(userId, buildingType, gridX, gridZ, buildingConfig)
local plot = plotData[userId]
if not plot then return false, "No plot" end
local config = getBuildingConfig(buildingType)
if not config then return false, "Invalid building" end
-- Check bounds
local size = config.gridSize or 1
if gridX < 1 or gridX + size - 1 > PLOT_SIZE or
gridZ < 1 or gridZ + size - 1 > PLOT_SIZE then
return false, "Out of bounds"
end
-- Check space
for x = gridX, gridX + size - 1 do
for z = gridZ, gridZ + size - 1 do
if plot.grid[z] and plot.grid[x][z] then
return false, "Space occupied"
end
end
end
-- Check adjacency to existing (if required)
if config.requiresAdjacency then
local adjacent = false
local checkAdj = { {-1,0}, {1,0}, {0,-1}, {0,1} }
for _, offset in checkAdj do
local ax, az = gridX + offset[1], gridZ + offset[2]
if ax >= 1 and ax <= PLOT_SIZE and az >= 1 and az <= PLOT_SIZE then
if plot.grid[ax] and plot.grid[ax][az] then
adjacent = true
break
end
end
end
if not adjacent then
return false, "Must be adjacent to existing building"
end
end
-- Check cost
local eco = require(script.Parent.EconomySystem)
if not eco.hasCurrency(userId, config.costType, config.costAmount) then
return false, "Insufficient funds"
end
return true
end
function GridManager.placeBuilding(userId, buildingType, gridX, gridZ)
local plot = plotData[userId]
if not plot then return false end
local config = getBuildingConfig(buildingType)
if not config then return false end
local canPlace, err = GridManager.canPlace(userId, buildingType, gridX, gridZ, config)
if not canPlace then
return false, err
end
-- Deduct cost
local eco = require(script.Parent.EconomySystem)
eco.spendCurrency(userId, config.costType, config.costAmount)
-- Place
local size = config.gridSize or 1
for x = gridX, gridX + size - 1 do
for z = gridZ, gridZ + size - 1 do
plot.grid[x][z] = true
end
end
local buildingId = generateUniqueId()
table.insert(plot.buildings, {
type = buildingType,
gridX = gridX,
gridZ = gridZ,
id = buildingId,
level = 1
})
-- Replicate to client
local placeEvent = ReplicatedStorage.Remotes.BuildingPlaced
if placeEvent then
local player = Players:GetPlayerByUserId(userId)
if player then
placeEvent:FireClient(player, {
buildingType = buildingType,
gridX = gridX,
gridZ = gridZ,
id = buildingId
})
end
end
return true, buildingId
end
-- Ghost preview (client-side for responsiveness, validated server-side)
-- Client sends desired position, server snaps to grid and validates
function GridManager.getGhostPosition(mousePosition)
local gridX = math.floor(mousePosition.X / GRID_SIZE + 0.5)
local gridZ = math.floor(mousePosition.Z / GRID_SIZE + 0.5)
local worldPos = Vector3.new(gridX * GRID_SIZE, 0, gridZ * GRID_SIZE)
return gridX, gridZ, worldPos
end
-- Tycoon: Dripper processing
function GridManager.processDrippers(userId)
local plot = plotData[userId]
if not plot then return end
for _, building in plot.buildings do
if building.type == "Dripper" then
local config = getBuildingConfig("Dripper")
local rate = config.baseRate * (1 + (building.level - 1) * 0.5)
local amount = rate * plot.incomeMultiplier
local eco = require(script.Parent.EconomySystem)
eco.grantCurrency(userId, "Coins", amount)
end
end
end
-- Rebirth
function GridManager.rebirth(userId)
local plot = plotData[userId]
if not plot then return false end
local rebirthCost = 1000 * (plot.rebirthCount + 1) ^ 2
local eco = require(script.Parent.EconomySystem)
if not eco.hasCurrency(userId, "Coins", rebirthCost) then
return false, "Need " .. rebirthCost .. " coins to rebirth"
end
eco.spendCurrency(userId, "Coins", rebirthCost)
plot.rebirthCount += 1
plot.incomeMultiplier = 1 + plot.rebirthCount * 0.25
-- Clear plot
for x = 1, PLOT_SIZE do
for z = 1, PLOT_SIZE do
plot.grid[x][z] = nil
end
end
table.clear(plot.buildings)
-- Replicate clear
local player = Players:GetPlayerByUserId(userId)
if player then
ReplicatedStorage.Remotes.ClearPlot:FireClient(player)
end
return true, plot.rebirthCount
end
return GridManager
12. Multiplayer & Team Systems
Multiplayer TeamsUse Teams service for team assignment, auto-balance to keep teams even, party system to keep friends together via TeleportService, and matchmaking to queue players.
Team Assignment
-- TeamManager.server.luau
local Teams = game:GetService("Teams")
local TeamManager = {}
-- Define teams
local function setupTeams()
local red = Instance.new("Team")
red.Name = "Red"
red.TeamColor = BrickColor.new("Bright red")
red.AutoAssignable = true
red.Parent = Teams
local blue = Instance.new("Team")
blue.Name = "Blue"
blue.TeamColor = BrickColor.new("Bright blue")
blue.AutoAssignable = true
blue.Parent = Teams
local spectators = Instance.new("Team")
spectators.Name = "Spectators"
spectators.TeamColor = BrickColor.new("White")
spectators.AutoAssignable = false
spectators.Parent = Teams
end
function TeamManager.init()
setupTeams()
end
function TeamManager.assignTeam(player, teamName)
local team = Teams:FindFirstChild(teamName)
if not team or team.Name == "Spectators" then return false end
player.Team = team
return true
end
function TeamManager.autoBalance(player)
local redCount = #Teams.Red:GetPlayers()
local blueCount = #Teams.Blue:GetPlayers()
if redCount <= blueCount then
player.Team = Teams.Red
else
player.Team = Teams.Blue
end
end
function TeamManager.autoBalanceAll()
local players = Players:GetPlayers()
local redTeam = {}
local blueTeam = {}
-- Separate existing team members
for _, p in players do
if p.Team == Teams.Red then
table.insert(redTeam, p)
elseif p.Team == Teams.Blue then
table.insert(blueTeam, p)
end
end
-- Balance by moving excess
while #redTeam > #blueTeam + 1 do
local moved = table.remove(redTeam)
moved.Team = Teams.Blue
table.insert(blueTeam, moved)
end
while #blueTeam > #redTeam + 1 do
local moved = table.remove(blueTeam)
moved.Team = Teams.Red
table.insert(redTeam, moved)
end
end
-- Party system using MemoryStore (for matchmaking across servers)
function TeamManager.createParty(leaderId, memberIds)
local party = {
leader = leaderId,
members = memberIds,
id = generateUniqueId()
}
-- Store in MemoryStore
local memoryStore = game:GetService("MemoryStoreService"):GetHashMap("Parties")
memoryStore:SetAsync(tostring(party.id), party, 3600) -- 1 hour TTL
return party
end
-- Teleport matchmaking
function TeamManager.joinMatchmaking(player, queueName)
local teleportService = game:GetService("TeleportService")
local memoryStore = game:GetService("MemoryStoreService"):GetQueue(queueName)
-- Add to queue
memoryStore:AddAsync({
userId = player.UserId,
partyId = nil, -- or party ID if in a party
timestamp = os.time()
})
-- Try to match
local matchResult = memoryStore:ReadAsync(2) -- Try to get 2 players
if matchResult and #matchResult >= 2 then
-- Found match! Teleport both to a game server
local players = {}
for _, entry in matchResult do
local p = Players:GetPlayerByUserId(entry.userId)
if p then
table.insert(players, p)
end
end
if #players >= 2 then
teleportService:TeleportPartyAsync(game.PlaceId, players)
end
end
end
-- Player join handler
Players.PlayerAdded:Connect(function(player)
-- Default to spectators
player.Team = Teams.Spectators
-- Wait for character
player.CharacterAdded:Connect(function(character)
task.wait(1) -- Allow loading
TeamManager.autoBalance(player)
end)
end)
return TeamManager
TeamManager.switchTeam(player) that swaps the player to the other team only if it won't create an imbalance of more than 1 player. Add a cooldown so players can't switch more than once every 60 seconds.
13. Shop & Economy Systems
Economy ShopShops have an in-game UI (ScreenGui) that lists purchasable items. The server validates every purchase. Support buyback (resell items at reduced price), daily rewards, and soft vs hard currency (soft = earned in-game, hard = Robux).
Shop Handler
-- ShopManager.server.luau
local EconomySystem = require(script.Parent.EconomySystem)
local InventoryManager = require(script.Parent.InventoryManager)
local ShopManager = {}
-- Shop listing
local SHOP_ITEMS = {
HealthPotion = {
itemId = "HealthPotion",
name = "Health Potion",
description = "Restores 50 HP",
price = 50,
currency = "Coins", -- soft currency
category = "Consumables",
stock = -1 -- infinite
},
IronSword = {
itemId = "IronSword",
name = "Iron Sword",
description = "A sturdy iron blade",
price = 200,
currency = "Coins",
category = "Weapons",
stock = 10
},
XPBoost = {
itemId = "XPBoost",
name = "XP Boost (1 Hour)",
description = "Double XP for 1 hour",
price = 50,
currency = "Gems", -- premium currency
category = "Boosts",
stock = -1,
isGamepass = true
},
ExclusivePet = {
itemId = "ExclusivePet",
name = "Dragon Pet",
description = "Exclusive limited pet",
price = 499,
currency = "Robux", -- hard currency
category = "Pets",
stock = 100,
isGamepass = true,
productId = 12345678 -- DevProduct ID
}
}
-- Daily rewards
local DAILY_REWARDS = {
{ day = 1, reward = { type = "Coins", amount = 100 } },
{ day = 2, reward = { type = "Coins", amount = 150 } },
{ day = 3, reward = { type = "Item", id = "HealthPotion", quantity = 2 } },
{ day = 4, reward = { type = "Coins", amount = 200 } },
{ day = 5, reward = { type = "Gems", amount = 10 } },
{ day = 6, reward = { type = "Item", id = "IronSword", quantity = 1 } },
{ day = 7, reward = { type = "Gems", amount = 50 } }
}
function ShopManager.getShopListing()
local listing = {}
for _, item in SHOP_ITEMS do
table.insert(listing, item)
end
return listing
end
function ShopManager.purchaseItem(player, itemId)
local item = SHOP_ITEMS[itemId]
if not item then
return false, "Item not found"
end
-- Check stock
if item.stock > 0 then
-- Track stock across server (use a shared value)
local stockKey = "ShopStock_" .. itemId
local currentStock = _G[stockKey] or item.stock
if currentStock <= 0 then
return false, "Out of stock"
end
_G[stockKey] = currentStock - 1
end
-- Process payment based on currency type
if item.currency == "Robux" then
-- Use MarketplaceService for Robux purchases
local marketplace = game:GetService("MarketplaceService")
local success, result = pcall(function()
return marketplace:PromptProductPurchase(player, item.productId)
end)
if not success then
return false, "Purchase cancelled"
end
elseif item.currency == "Gems" then
if not EconomySystem.spendCurrency(player.UserId, "Gems", item.price) then
return false, "Not enough Gems"
end
else -- Coins
if not EconomySystem.spendCurrency(player.UserId, "Coins", item.price) then
return false, "Not enough Coins"
end
end
-- Grant item
if item.isGamepass then
-- Grant gamepass
EconomySystem.grantGamepass(player.UserId, itemId)
else
InventoryManager.addItem(player.UserId, itemId, 1)
end
return true, "Purchase successful"
end
-- Buyback: resell item at 50% price
function ShopManager.buybackItem(player, itemId, quantity)
quantity = quantity or 1
local item = SHOP_ITEMS[itemId]
if not item then return false end
local inv = require(script.Parent.InventoryManager)
if not inv.removeItem(player.UserId, itemId, quantity) then
return false, "Item not in inventory"
end
local refundAmount = math.floor(item.price * 0.5 * quantity)
EconomySystem.grantCurrency(player.UserId, item.currency, refundAmount)
return true, "Refunded " .. refundAmount .. " " .. item.currency
end
-- Daily reward claim
function ShopManager.claimDailyReward(player)
local data = DataStoreManager.getPlayerData(player.UserId)
if not data then return false end
local today = os.date("%Y-%m-%d")
if data.lastDailyReward == today then
return false, "Already claimed today"
end
-- Streak tracking
local yesterday = os.date("%Y-%m-%d", os.time() - 86400)
if data.lastDailyReward == yesterday then
data.dailyStreak += 1
else
data.dailyStreak = 1
end
data.lastDailyReward = today
local streakDay = math.min(data.dailyStreak, #DAILY_REWARDS)
local reward = DAILY_REWARDS[streakDay]
-- Grant reward
if reward.reward.type == "Coins" then
EconomySystem.grantCurrency(player.UserId, "Coins", reward.reward.amount)
elseif reward.reward.type == "Gems" then
EconomySystem.grantCurrency(player.UserId, "Gems", reward.reward.amount)
elseif reward.reward.type == "Item" then
InventoryManager.addItem(player.UserId, reward.reward.id, reward.reward.quantity)
end
return true, {
streak = data.dailyStreak,
reward = reward.reward
}
end
return ShopManager
Sale table with start/end time, discount percentage, and affected items. During a sale, adjust prices in the shop UI by the discount. Use os.time() to check if sale is active.
14. Crafting & Upgrading
Crafting UpgradesCrafting combines materials into new items. Upgrading improves existing items with tier levels and success/failure chances. Recipes are defined in a ModuleScript with material costs, required station, and output.
Crafting System
-- CraftingSystem.server.luau
local InventoryManager = require(script.Parent.InventoryManager)
local CraftingSystem = {}
-- Recipe definitions
local RECIPES = {
IronSword = {
id = "IronSword",
name = "Iron Sword",
description = "Smelt iron ore into a blade",
station = "Anvil",
craftTime = 3, -- seconds
materials = {
{ itemId = "IronOre", quantity = 5 },
{ itemId = "Wood", quantity = 2 }
},
output = { itemId = "IronSword", quantity = 1 },
level = 1
},
SteelArmor = {
id = "SteelArmor",
name = "Steel Armor",
description = "Combine iron and coal for steel armor",
station = "Forge",
craftTime = 8,
materials = {
{ itemId = "IronOre", quantity = 10 },
{ itemId = "Coal", quantity = 5 },
{ itemId = "Leather", quantity = 3 }
},
output = { itemId = "SteelArmor", quantity = 1 },
level = 2
},
HealthPotion = {
id = "HealthPotion",
name = "Health Potion",
description = "Brew a healing potion",
station = "Cauldron",
craftTime = 2,
materials = {
{ itemId = "Herb", quantity = 3 },
{ itemId = "WaterVial", quantity = 1 }
},
output = { itemId = "HealthPotion", quantity = 2 },
level = 1
}
}
-- Upgrade tiers
local UPGRADE_TIERS = {
{ level = 1, name = "+1", successChance = 0.9, costMultiplier = 1 },
{ level = 2, name = "+2", successChance = 0.75, costMultiplier = 1.5 },
{ level = 3, name = "+3", successChance = 0.6, costMultiplier = 2 },
{ level = 4, name = "+4", successChance = 0.4, costMultiplier = 3 },
{ level = 5, name = "+5", successChance = 0.25, costMultiplier = 5 },
{ level = 6, name = "+6", successChance = 0.15, costMultiplier = 8 },
{ level = 7, name = "+7", successChance = 0.08, costMultiplier = 13 },
{ level = 8, name = "+8", successChance = 0.04, costMultiplier = 21 },
{ level = 9, name = "+9", successChance = 0.02, costMultiplier = 34 },
{ level = 10, name = "+10", successChance = 0.01, costMultiplier = 55 }
}
local function getBaseCost(itemId)
-- Get base material cost for upgrade (1 of each original material)
for recipeId, recipe in RECIPES do
if recipe.output.itemId == itemId then
return recipe.materials
end
end
return {}
end
function CraftingSystem.canCraft(userId, recipeId)
local recipe = RECIPES[recipeId]
if not recipe then return false, "Recipe not found" end
local inv = InventoryManager.getInventory(userId)
if not inv then return false, "No inventory" end
for _, mat in recipe.materials do
local found = false
for _, entry in inv.items do
if entry.itemId == mat.itemId and entry.quantity >= mat.quantity then
found = true
break
end
end
if not found then
return false, "Missing material: " .. mat.itemId
end
end
return true
end
function CraftingSystem.craftItem(userId, recipeId)
local recipe = RECIPES[recipeId]
if not recipe then return false, "Recipe not found" end
local canCraft, err = CraftingSystem.canCraft(userId, recipeId)
if not canCraft then return false, err end
-- Consume materials
for _, mat in recipe.materials do
InventoryManager.removeItem(userId, mat.itemId, mat.quantity)
end
-- Add output (with craft time delay)
task.wait(recipe.craftTime)
InventoryManager.addItem(userId, recipe.output.itemId, recipe.output.quantity)
-- Fire event for quest tracking
local craftEvent = ReplicatedStorage:FindFirstChild("Events")
if craftEvent then
craftEvent.ItemCrafted:Fire(Players:GetPlayerByUserId(userId))
end
return true, recipe.output.itemId
end
-- Upgrade system
function CraftingSystem.canUpgrade(userId, itemId)
local inv = InventoryManager.getInventory(userId)
if not inv then return false end
-- Find item and get current upgrade level
local itemEntry = nil
for _, entry in inv.items do
if entry.itemId == itemId then
itemEntry = entry
break
end
end
if not itemEntry then return false, "Item not found" end
local currentLevel = itemEntry.upgradeLevel or 0
local nextTier = UPGRADE_TIERS[currentLevel + 1]
if not nextTier then
return false, "Max upgrade level reached"
end
-- Check materials (base cost * multiplier)
local baseMats = getBaseCost(itemId)
if #baseMats == 0 then return false, "Item cannot be upgraded" end
for _, mat in baseMats do
local requiredQty = math.floor(mat.quantity * nextTier.costMultiplier)
local found = false
for _, entry in inv.items do
if entry.itemId == mat.itemId and entry.quantity >= requiredQty then
found = true
break
end
end
if not found then
return false, "Not enough " .. mat.itemId
end
end
return true, nextTier
end
function CraftingSystem.upgradeItem(userId, itemId)
local canUpgrade, data = CraftingSystem.canUpgrade(userId, itemId)
if not canUpgrade then return false, data end
local nextTier = data
-- Consume materials
local baseMats = getBaseCost(itemId)
for _, mat in baseMats do
local requiredQty = math.floor(mat.quantity * nextTier.costMultiplier)
InventoryManager.removeItem(userId, mat.itemId, requiredQty)
end
-- Roll for success
local roll = math.random()
local success = roll <= nextTier.successChance
if success then
-- Increase upgrade level
local inv = InventoryManager.getInventory(userId)
for _, entry in inv.items do
if entry.itemId == itemId then
entry.upgradeLevel = (entry.upgradeLevel or 0) + 1
break
end
end
-- Visual/audio feedback
local successEvent = ReplicatedStorage.Remotes.UpgradeSuccess
if successEvent then
successEvent:FireClient(Players:GetPlayerByUserId(userId), itemId, nextTier.level)
end
return true, "Upgrade to " .. nextTier.name .. " successful!"
else
-- Failure: degrade or destroy
local FAILURE_DESTROY_CHANCE = 0.3
local destroyRoll = math.random()
if destroyRoll <= FAILURE_DESTROY_CHANCE then
-- Item destroyed
InventoryManager.removeItem(userId, itemId, 1)
return false, "Upgrade failed! Item was destroyed."
else
-- Decrease level
local inv = InventoryManager.getInventory(userId)
for _, entry in inv.items do
if entry.itemId == itemId then
entry.upgradeLevel = math.max(0, (entry.upgradeLevel or 0) - 1)
break
end
end
return false, "Upgrade failed! Item level decreased."
end
end
end
return CraftingSystem
bulkCraft(userId, recipeId, count) function that crafts multiple items at once. Deduct all materials upfront, calculate total craft time, and grant all outputs at once. Add a progress bar notification for the player.
15. Ragdoll & Death Effects
Combat EffectsWhen a character dies, the server triggers a ragdoll: disable Anchored on all parts, apply random velocity, disable collisions between body parts, and set the Humanoid to HumanoidStateType.Dead. After a respawn timer, load a fresh character.
Death Handler
-- DeathHandler.server.luau
local DeathHandler = {}
local RESPAWN_TIME = 5
function DeathHandler.ragdoll(character)
if not character then return end
local humanoid = character:FindFirstChild("Humanoid")
if not humanoid then return end
-- Set state to dead
humanoid:SetStateEnabled(Enum.HumanoidStateType.Dead, true)
humanoid:ChangeState(Enum.HumanoidStateType.Dead)
-- Detach all joints
local rootPart = character:FindFirstChild("HumanoidRootPart")
if rootPart then
-- Unanchor all parts
for _, part in character:GetDescendants() do
if part:IsA("BasePart") then
part.Anchored = false
part.CanCollide = false
-- Apply random velocity for ragdoll effect
local velocity = Vector3.new(
math.random(-20, 20),
math.random(5, 15),
math.random(-20, 20)
)
part.AssemblyLinearVelocity = velocity
part.AssemblyAngularVelocity = Vector3.new(
math.random(-10, 10),
math.random(-10, 10),
math.random(-10, 10)
)
end
end
-- Disable all Motor6Ds (destroy joints)
for _, joint in character:GetDescendants() do
if joint:IsA("Motor6D") then
joint:Destroy()
end
end
end
-- Disable collisions between character parts
for _, part1 in character:GetDescendants() do
if part1:IsA("BasePart") then
for _, part2 in character:GetDescendants() do
if part2:IsA("BasePart") and part1 ~= part2 then
local constraint = Instance.new("NoCollisionConstraint")
constraint.Part0 = part1
constraint.Part1 = part2
constraint.Parent = part1
end
end
end
end
-- Play death sound
local deathSound = character:FindFirstChild("DeathSound")
if deathSound then
deathSound:Play()
else
local sound = Instance.new("Sound")
sound.SoundId = "rbxassetid://9120391559" -- generic death sound
sound.Parent = rootPart or character
sound:Play()
end
end
function DeathHandler.respawnPlayer(player)
-- Load character from storage
local humanoid = player.Character and player.Character:FindFirstChild("Humanoid")
if humanoid then
humanoid.Health = 0
end
-- Wait for respawn timer
task.wait(RESPAWN_TIME)
-- Respawn using LoadCharacter
player:LoadCharacter()
-- Apply respawn effects
player.CharacterAdded:Wait()
local newChar = player.Character
if newChar then
-- Brief invincibility
local newHumanoid = newChar:FindFirstChild("Humanoid")
if newHumanoid then
newHumanoid:SetStateEnabled(Enum.HumanoidStateType.Dead, true)
-- Visual effect: spawn protection glow
local highlight = Instance.new("Highlight")
highlight.FillColor = Color3.fromRGB(0, 255, 255)
highlight.FillTransparency = 0.5
highlight.OutlineColor = Color3.fromRGB(255, 255, 255)
highlight.Parent = newChar
-- Remove after 3 seconds
task.delay(3, function()
if highlight and highlight.Parent then
highlight:Destroy()
end
end)
end
end
end
function DeathHandler.onCharacterDied(player)
local character = player.Character
if not character then return end
-- Get killer info before ragdoll
local killer = character:FindFirstChild("Killer")
local killData = {
killer = killer and killer.Value or nil,
weapon = character:FindFirstChild("KillWeapon") and character:FindFirstChild("KillWeapon").Value or nil
}
-- Ragdoll
DeathHandler.ragdoll(character)
-- Update leaderstats (deaths)
local ls = player:FindFirstChild("leaderstats")
if ls and ls:FindFirstChild("Deaths") then
ls.Deaths.Value += 1
end
-- Grant kill credit
if killData.killer then
local killerPlayer = Players:GetPlayerByUserId(killData.killer)
if killerPlayer then
local killerLS = killerPlayer:FindFirstChild("leaderstats")
if killerLS and killerLS:FindFirstChild("Kills") then
killerLS.Kills.Value += 1
end
-- Grant XP for kill
local levelSys = require(script.Parent.LevelSystem)
levelSys.grantXP(killerPlayer, "KillEnemy")
end
end
-- Drop loot
local lootTable = require(script.Parent.LootTable)
local drops = lootTable.roll("Player", player.UserId)
for _, drop in drops do
lootTable.spawnPickup(character.HumanoidRootPart.Position, drop.itemId, drop.quantity)
end
-- Respawn
DeathHandler.respawnPlayer(player)
end
-- Connect to player events
Players.PlayerAdded:Connect(function(player)
player.CharacterAdded:Connect(function(character)
local humanoid = character:FindFirstChild("Humanoid")
if humanoid then
humanoid.Died:Connect(function()
DeathHandler.onCharacterDied(player)
end)
end
end)
end)
return DeathHandler