game-systems

SKILL.md

Roblox Game Systems

When implementing game systems, follow these patterns for robust and exploiter-resistant mechanics.

Inventory System

Slot-Based Inventory

local InventoryService = {}

local DEFAULT_SLOTS = 20
local MAX_STACK = 99

function InventoryService.create(maxSlots)
    return {
        slots = {},
        maxSlots = maxSlots or DEFAULT_SLOTS
    }
end

function InventoryService.addItem(inventory, itemId, quantity)
    quantity = quantity or 1

    -- Try to stack with existing
    for slotIndex, slot in pairs(inventory.slots) do
        if slot.itemId == itemId and slot.quantity < MAX_STACK then
            local canAdd = math.min(quantity, MAX_STACK - slot.quantity)
            slot.quantity = slot.quantity + canAdd
            quantity = quantity - canAdd

            if quantity <= 0 then
                return true, slotIndex
            end
        end
    end

    -- Find empty slots for remaining
    while quantity > 0 do
        local emptySlot = InventoryService.findEmptySlot(inventory)
        if not emptySlot then
            return false, "Inventory full"
        end

        local stackSize = math.min(quantity, MAX_STACK)
        inventory.slots[emptySlot] = {
            itemId = itemId,
            quantity = stackSize
        }
        quantity = quantity - stackSize
    end

    return true
end

function InventoryService.removeItem(inventory, itemId, quantity)
    quantity = quantity or 1
    local removed = 0

    -- Remove from slots (prefer partial stacks first)
    local slots = {}
    for slotIndex, slot in pairs(inventory.slots) do
        if slot.itemId == itemId then
            table.insert(slots, {index = slotIndex, quantity = slot.quantity})
        end
    end

    table.sort(slots, function(a, b) return a.quantity < b.quantity end)

    for _, slotInfo in ipairs(slots) do
        local slot = inventory.slots[slotInfo.index]
        local toRemove = math.min(quantity - removed, slot.quantity)

        slot.quantity = slot.quantity - toRemove
        removed = removed + toRemove

        if slot.quantity <= 0 then
            inventory.slots[slotInfo.index] = nil
        end

        if removed >= quantity then
            break
        end
    end

    return removed >= quantity, removed
end

function InventoryService.hasItem(inventory, itemId, quantity)
    quantity = quantity or 1
    local total = 0

    for _, slot in pairs(inventory.slots) do
        if slot.itemId == itemId then
            total = total + slot.quantity
            if total >= quantity then
                return true
            end
        end
    end

    return false
end

function InventoryService.getItemCount(inventory, itemId)
    local total = 0
    for _, slot in pairs(inventory.slots) do
        if slot.itemId == itemId then
            total = total + slot.quantity
        end
    end
    return total
end

function InventoryService.findEmptySlot(inventory)
    for i = 1, inventory.maxSlots do
        if not inventory.slots[i] then
            return i
        end
    end
    return nil
end

Shop System

Server-Side Shop with Validation

local ShopService = {}

local ShopItems = {
    sword_basic = {price = 100, currency = "coins", category = "weapons"},
    potion_health = {price = 50, currency = "coins", category = "consumables"},
    vip_pass = {price = 499, currency = "robux", productId = 123456789}
}

function ShopService.canPurchase(player, itemId, quantity)
    quantity = quantity or 1
    local item = ShopItems[itemId]

    if not item then
        return false, "Item not found"
    end

    if item.currency == "robux" then
        -- Robux purchases handled differently
        return true, "Use promptPurchase"
    end

    local playerCurrency = DataManager.get(player, item.currency) or 0
    local totalCost = item.price * quantity

    if playerCurrency < totalCost then
        return false, "Not enough " .. item.currency
    end

    -- Check inventory space
    local inventory = DataManager.get(player, "inventory")
    local emptySlots = InventoryService.countEmptySlots(inventory)

    if emptySlots < math.ceil(quantity / MAX_STACK) then
        return false, "Inventory full"
    end

    return true, totalCost
end

function ShopService.purchase(player, itemId, quantity)
    quantity = quantity or 1

    local canBuy, result = ShopService.canPurchase(player, itemId, quantity)
    if not canBuy then
        return false, result
    end

    local item = ShopItems[itemId]

    if item.currency == "robux" then
        -- Prompt Robux purchase
        MarketplaceService:PromptProductPurchase(player, item.productId)
        return true, "Purchase prompted"
    end

    -- Deduct currency (atomic operation)
    local success = DataManager.removeCurrency(player, item.currency, result)
    if not success then
        return false, "Transaction failed"
    end

    -- Add item
    local inventory = DataManager.get(player, "inventory")
    local added = InventoryService.addItem(inventory, itemId, quantity)

    if not added then
        -- Rollback currency
        DataManager.addCurrency(player, item.currency, result)
        return false, "Failed to add item"
    end

    return true, "Purchase successful"
end

-- Handle Robux purchases
MarketplaceService.ProcessReceipt = function(receiptInfo)
    local player = Players:GetPlayerByUserId(receiptInfo.PlayerId)
    if not player then
        return Enum.ProductPurchaseDecision.NotProcessedYet
    end

    local productId = receiptInfo.ProductId
    local itemId = getItemByProductId(productId)

    if not itemId then
        return Enum.ProductPurchaseDecision.NotProcessedYet
    end

    local inventory = DataManager.get(player, "inventory")
    local added = InventoryService.addItem(inventory, itemId, 1)

    if added then
        DataManager.save(player)
        return Enum.ProductPurchaseDecision.PurchaseGranted
    end

    return Enum.ProductPurchaseDecision.NotProcessedYet
end

Trading System

Secure Trading

local TradingService = {}
TradingService.activeTrades = {}

function TradingService.requestTrade(player1, player2)
    local tradeId = HttpService:GenerateGUID()

    TradingService.activeTrades[tradeId] = {
        player1 = {
            player = player1,
            items = {},
            confirmed = false
        },
        player2 = {
            player = player2,
            items = {},
            confirmed = false
        },
        status = "pending",
        createdAt = os.clock()
    }

    -- Notify player2
    TradeRequestRemote:FireClient(player2, player1.Name, tradeId)

    return tradeId
end

function TradingService.addItem(tradeId, player, itemSlot)
    local trade = TradingService.activeTrades[tradeId]
    if not trade or trade.status ~= "active" then
        return false, "Invalid trade"
    end

    local side = trade.player1.player == player and trade.player1 or
                 trade.player2.player == player and trade.player2

    if not side then
        return false, "Not in this trade"
    end

    -- Reset confirmations when items change
    trade.player1.confirmed = false
    trade.player2.confirmed = false

    -- Validate player owns the item
    local inventory = DataManager.get(player, "inventory")
    local item = inventory.slots[itemSlot]

    if not item then
        return false, "Item not found"
    end

    -- Check item isn't already in trade
    for _, existingSlot in ipairs(side.items) do
        if existingSlot == itemSlot then
            return false, "Item already in trade"
        end
    end

    table.insert(side.items, itemSlot)

    -- Update both players' UI
    TradeUpdateRemote:FireClient(trade.player1.player, tradeId, trade)
    TradeUpdateRemote:FireClient(trade.player2.player, tradeId, trade)

    return true
end

function TradingService.confirmTrade(tradeId, player)
    local trade = TradingService.activeTrades[tradeId]
    if not trade or trade.status ~= "active" then
        return false
    end

    local side = trade.player1.player == player and trade.player1 or
                 trade.player2.player == player and trade.player2

    if not side then return false end

    side.confirmed = true

    -- Check if both confirmed
    if trade.player1.confirmed and trade.player2.confirmed then
        return TradingService.executeTrade(tradeId)
    end

    -- Update UI
    TradeUpdateRemote:FireClient(trade.player1.player, tradeId, trade)
    TradeUpdateRemote:FireClient(trade.player2.player, tradeId, trade)

    return true
end

function TradingService.executeTrade(tradeId)
    local trade = TradingService.activeTrades[tradeId]
    trade.status = "executing"

    local p1 = trade.player1.player
    local p2 = trade.player2.player
    local inv1 = DataManager.get(p1, "inventory")
    local inv2 = DataManager.get(p2, "inventory")

    -- Validate both players still have the items
    for _, slot in ipairs(trade.player1.items) do
        if not inv1.slots[slot] then
            TradingService.cancelTrade(tradeId, "Item no longer available")
            return false
        end
    end

    for _, slot in ipairs(trade.player2.items) do
        if not inv2.slots[slot] then
            TradingService.cancelTrade(tradeId, "Item no longer available")
            return false
        end
    end

    -- Execute swap atomically
    local p1Items = {}
    local p2Items = {}

    -- Remove items from player 1
    for _, slot in ipairs(trade.player1.items) do
        table.insert(p1Items, inv1.slots[slot])
        inv1.slots[slot] = nil
    end

    -- Remove items from player 2
    for _, slot in ipairs(trade.player2.items) do
        table.insert(p2Items, inv2.slots[slot])
        inv2.slots[slot] = nil
    end

    -- Add player 1's items to player 2
    for _, item in ipairs(p1Items) do
        InventoryService.addItem(inv2, item.itemId, item.quantity)
    end

    -- Add player 2's items to player 1
    for _, item in ipairs(p2Items) do
        InventoryService.addItem(inv1, item.itemId, item.quantity)
    end

    -- Save both players
    DataManager.save(p1)
    DataManager.save(p2)

    -- Complete trade
    trade.status = "completed"
    TradingService.activeTrades[tradeId] = nil

    TradeCompleteRemote:FireClient(p1, tradeId, true)
    TradeCompleteRemote:FireClient(p2, tradeId, true)

    return true
end

Quest System

Quest Manager

local QuestService = {}

local QuestDefinitions = {
    kill_enemies_1 = {
        title = "Enemy Slayer",
        description = "Defeat 10 enemies",
        objectives = {
            {type = "kill", target = "enemy", count = 10}
        },
        rewards = {
            {type = "currency", currency = "coins", amount = 100},
            {type = "experience", amount = 50}
        }
    },
    collect_items_1 = {
        title = "Collector",
        description = "Collect 5 gems",
        objectives = {
            {type = "collect", item = "gem", count = 5}
        },
        rewards = {
            {type = "currency", currency = "coins", amount = 200}
        }
    }
}

function QuestService.startQuest(player, questId)
    local quest = QuestDefinitions[questId]
    if not quest then return false end

    local playerQuests = DataManager.get(player, "activeQuests") or {}

    -- Check not already active
    if playerQuests[questId] then
        return false, "Quest already active"
    end

    -- Initialize progress
    playerQuests[questId] = {
        startedAt = os.time(),
        progress = {}
    }

    for i, objective in ipairs(quest.objectives) do
        playerQuests[questId].progress[i] = 0
    end

    DataManager.set(player, "activeQuests", playerQuests)
    return true
end

function QuestService.updateProgress(player, eventType, eventData)
    local playerQuests = DataManager.get(player, "activeQuests") or {}
    local updated = false

    for questId, questProgress in pairs(playerQuests) do
        local quest = QuestDefinitions[questId]
        if not quest then continue end

        for i, objective in ipairs(quest.objectives) do
            if objective.type == eventType then
                local matches = true

                -- Check target matches
                if objective.target and eventData.target ~= objective.target then
                    matches = false
                end
                if objective.item and eventData.item ~= objective.item then
                    matches = false
                end

                if matches and questProgress.progress[i] < objective.count then
                    questProgress.progress[i] = questProgress.progress[i] + (eventData.amount or 1)
                    updated = true

                    -- Check if quest completed
                    if QuestService.isQuestComplete(questId, questProgress) then
                        QuestService.completeQuest(player, questId)
                    end
                end
            end
        end
    end

    if updated then
        DataManager.set(player, "activeQuests", playerQuests)
    end
end

function QuestService.isQuestComplete(questId, progress)
    local quest = QuestDefinitions[questId]

    for i, objective in ipairs(quest.objectives) do
        if progress.progress[i] < objective.count then
            return false
        end
    end

    return true
end

function QuestService.completeQuest(player, questId)
    local quest = QuestDefinitions[questId]
    local playerQuests = DataManager.get(player, "activeQuests")

    -- Remove from active
    playerQuests[questId] = nil
    DataManager.set(player, "activeQuests", playerQuests)

    -- Add to completed
    local completedQuests = DataManager.get(player, "completedQuests") or {}
    completedQuests[questId] = os.time()
    DataManager.set(player, "completedQuests", completedQuests)

    -- Grant rewards
    for _, reward in ipairs(quest.rewards) do
        if reward.type == "currency" then
            DataManager.addCurrency(player, reward.currency, reward.amount)
        elseif reward.type == "experience" then
            LevelingService.addExperience(player, reward.amount)
        elseif reward.type == "item" then
            local inventory = DataManager.get(player, "inventory")
            InventoryService.addItem(inventory, reward.item, reward.quantity or 1)
        end
    end

    QuestCompleteRemote:FireClient(player, questId, quest.rewards)
end

Pet System

Pet Manager

local PetService = {}

local PetDefinitions = {
    cat_basic = {name = "Cat", rarity = "common", bonuses = {luck = 5}},
    dog_basic = {name = "Dog", rarity = "common", bonuses = {speed = 5}},
    dragon_epic = {name = "Dragon", rarity = "epic", bonuses = {damage = 20, luck = 10}}
}

function PetService.createPet(petType)
    local def = PetDefinitions[petType]
    if not def then return nil end

    return {
        id = HttpService:GenerateGUID(),
        type = petType,
        name = def.name,
        level = 1,
        experience = 0,
        equipped = false
    }
end

function PetService.equipPet(player, petId)
    local pets = DataManager.get(player, "pets") or {}

    -- Find the pet
    local targetPet
    for _, pet in ipairs(pets) do
        if pet.id == petId then
            targetPet = pet
        else
            pet.equipped = false  -- Unequip others
        end
    end

    if not targetPet then
        return false, "Pet not found"
    end

    targetPet.equipped = true
    DataManager.set(player, "pets", pets)

    -- Apply bonuses
    PetService.applyBonuses(player, targetPet)

    -- Spawn visual pet
    PetService.spawnPetVisual(player, targetPet)

    return true
end

function PetService.applyBonuses(player, pet)
    local def = PetDefinitions[pet.type]
    if not def then return end

    local levelMultiplier = 1 + (pet.level - 1) * 0.1  -- 10% per level

    for stat, value in pairs(def.bonuses) do
        local bonus = math.floor(value * levelMultiplier)
        player:SetAttribute("PetBonus_" .. stat, bonus)
    end
end

function PetService.spawnPetVisual(player, pet)
    local character = player.Character
    if not character then return end

    -- Remove existing pet visual
    local existing = character:FindFirstChild("PetModel")
    if existing then existing:Destroy() end

    local def = PetDefinitions[pet.type]
    local petModel = ReplicatedStorage.Pets[pet.type]:Clone()
    petModel.Name = "PetModel"
    petModel.Parent = character

    -- Pet following behavior
    task.spawn(function()
        local hrp = character:FindFirstChild("HumanoidRootPart")
        while petModel.Parent and hrp do
            local targetPos = hrp.Position + hrp.CFrame.RightVector * 3 + Vector3.new(0, 2, 0)
            local currentPos = petModel.PrimaryPart.Position

            local direction = (targetPos - currentPos)
            if direction.Magnitude > 0.5 then
                local newPos = currentPos + direction.Unit * math.min(direction.Magnitude, 0.5)
                petModel:PivotTo(CFrame.lookAt(newPos, hrp.Position))
            end

            task.wait()
        end
    end)
end

Leveling System

Experience & Leveling

local LevelingService = {}

-- XP required for each level: level^2 * 100
local function getRequiredXP(level)
    return level * level * 100
end

function LevelingService.addExperience(player, amount)
    local currentLevel = DataManager.get(player, "level") or 1
    local currentXP = DataManager.get(player, "experience") or 0

    currentXP = currentXP + amount

    -- Check for level ups
    local levelsGained = 0
    while currentXP >= getRequiredXP(currentLevel) do
        currentXP = currentXP - getRequiredXP(currentLevel)
        currentLevel = currentLevel + 1
        levelsGained = levelsGained + 1
    end

    DataManager.set(player, "level", currentLevel)
    DataManager.set(player, "experience", currentXP)

    if levelsGained > 0 then
        LevelingService.onLevelUp(player, currentLevel, levelsGained)
    end

    return currentLevel, currentXP, levelsGained
end

function LevelingService.onLevelUp(player, newLevel, levelsGained)
    -- Heal to full
    local character = player.Character
    if character then
        local humanoid = character:FindFirstChildOfClass("Humanoid")
        if humanoid then
            humanoid.Health = humanoid.MaxHealth
        end
    end

    -- Grant stat points
    local statPoints = DataManager.get(player, "statPoints") or 0
    DataManager.set(player, "statPoints", statPoints + levelsGained * 3)

    -- Unlock abilities
    local unlocks = AbilityUnlocks[newLevel]
    if unlocks then
        for _, abilityId in ipairs(unlocks) do
            AbilityService.unlock(player, abilityId)
        end
    end

    -- Visual/audio feedback
    LevelUpRemote:FireClient(player, newLevel)
end

function LevelingService.getProgress(player)
    local level = DataManager.get(player, "level") or 1
    local xp = DataManager.get(player, "experience") or 0
    local required = getRequiredXP(level)

    return {
        level = level,
        currentXP = xp,
        requiredXP = required,
        progress = xp / required
    }
end

Daily Rewards

Daily Login System

local DailyRewardService = {}

local DAILY_REWARDS = {
    {type = "coins", amount = 100},
    {type = "coins", amount = 150},
    {type = "coins", amount = 200},
    {type = "gems", amount = 5},
    {type = "coins", amount = 300},
    {type = "gems", amount = 10},
    {type = "item", itemId = "rare_chest", amount = 1}
}

function DailyRewardService.checkDailyReward(player)
    local lastLogin = DataManager.get(player, "lastLoginTime") or 0
    local loginStreak = DataManager.get(player, "loginStreak") or 0

    local now = os.time()
    local lastLoginDate = os.date("*t", lastLogin)
    local currentDate = os.date("*t", now)

    -- Check if it's a new day
    local isNewDay = lastLoginDate.yday ~= currentDate.yday or
                     lastLoginDate.year ~= currentDate.year

    if not isNewDay then
        return nil, "Already claimed today"
    end

    -- Check if streak continues or resets
    local hoursSinceLastLogin = (now - lastLogin) / 3600
    if hoursSinceLastLogin > 48 then
        loginStreak = 0  -- Reset streak
    end

    loginStreak = loginStreak + 1
    local rewardIndex = ((loginStreak - 1) % #DAILY_REWARDS) + 1
    local reward = DAILY_REWARDS[rewardIndex]

    -- Update data
    DataManager.set(player, "lastLoginTime", now)
    DataManager.set(player, "loginStreak", loginStreak)

    -- Grant reward
    if reward.type == "coins" or reward.type == "gems" then
        DataManager.addCurrency(player, reward.type, reward.amount)
    elseif reward.type == "item" then
        local inventory = DataManager.get(player, "inventory")
        InventoryService.addItem(inventory, reward.itemId, reward.amount)
    end

    return {
        day = loginStreak,
        reward = reward
    }
end
Weekly Installs
2
Installed on
codex2
claude-code2
windsurf1
opencode1
cursor1
antigravity1