Part 6: Game Systems

The biggest gameplay part — everything from combat to crafting.

1. Game Loop Design Patterns

Core Design Architecture

Your 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.

GenreState MachinePersistencePlayer JoinSession Length
Round-BasedYes (4-5 states)Per roundLobby only5-15 min
Open WorldNoFull DataStoreAnytimeVariable
TycoonPartialPlot saveAnytime30+ min
ObbyMinimalBest timeAnytime2-10 min
Battle RoyaleYes (4 states)Per matchLobby only10-25 min
Exercise: Implement a RoundService that cycles LOBBY (20s) → FIGHT (60s) → RESULTS (10s) and broadcasts the state via a RemoteEvent. Add a BindToClose handler that ends the round gracefully.

2. Combat System: Damage & Health

Combat System

Health 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
Exercise: Extend the damage system to support damage types (physical, magic, true) with resistance multipliers per type. Store resistances in the character's Configuration folder.

3. Equipment & Inventory System

Inventory System

An 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
Exercise: Add an 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 Pathfinding

Roblox 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

-- 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
Exercise: Add a "Flee" state when NPC health drops below 30%. The NPC runs to a random waypoint far from the player. After reaching it, switch to "Patrol" and slowly regenerate health.

5. NPC Spawning & Wave Systems

AI Spawning

Wave 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
Exercise: Add a 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 System

The 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
Exercise: Add a Prestige system. When a player reaches max level (e.g., 50), they can prestige — reset to level 1 but gain a permanent 10% XP boost. Track prestige count in data and apply the boost multiplier.

7. Quests & Achievements

Progression Quests

Quests 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
Exercise: Add a daily quest system. Generate 3 random quests each day from a pool. Store the generation timestamp and regenerate if 24h have passed. Show a daily quest UI with countdown timer.

8. Drop Tables & Loot Systems

Loot Random

Weighted 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
Exercise: Add a weighted drop function that uses 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 UX

Three 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)
Exercise: Create a combination lock that uses a ProximityPrompt to open a UI (number pad). Player enters a 4-digit code. If correct, the door opens. Store the code as an IntValue attribute on the lock.

10. Vehicle Systems

Vehicles Physics

Roblox 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)
Exercise: Add a boost mechanic. When the player presses Shift (left shift key), multiply throttle by 2.5 for 3 seconds, then cooldown for 5 seconds. Show a boost bar using a ScreenGui.

11. Building & Tycoon Systems

Building Tycoon

Grid-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
Exercise: Add a wall system. Walls block NPC pathfinding. When placed, update the NavMesh by adding a transparent Part with CanCollide = true. Use PathfindingService:ModifyCost for the blocked region.

12. Multiplayer & Team Systems

Multiplayer Teams

Use 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
Exercise: Implement a 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 Shop

Shops 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
Exercise: Add a limited-time sale system. Define a 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 Upgrades

Crafting 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
Exercise: Add a 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 Effects

When 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
Exercise: Add a death camera that orbits the ragdoll for 3 seconds before respawning. Use a BindToRenderStep on the client to smoothly follow the ragdoll's position while the player cannot move (character dead).