How to make a Roblox game currency
A Roblox game currency system gives players a sense of progression and an incentive to keep playing. Loot boxes build up anticipation and also add an element of randomness that drives players to keep coming back for more.
In-game currencies have proven themselves successful across mobile, console, and even PC games. It is everywhere!
For our Roblox game currency we’ll use developer products to allow repeatable purchases. We’ll store player’s purchases by using Roblox data stores.
What you’ll learn
- Design patterns to handle in-game purchases
- Create a loot box system
- Properly handle errors in your currency system
- Things to consider that attract, engage, and retain players
Storing player’s currency bank
First we need to come up with how our currency will work conceptually in the world we’re creating.
If we’re creating a fantasy style world we can use gold, silver, and copper coins.
In a survival type world we could use something that’s more abundant and relevant such as bottle caps like in the Fallout series. Dollars mean nothing when the world has collapsed into chaos, am I right?
From Robux to gold
I’m most familiar with the fantasy type world like World of Warcraft where 100 copper equals one silver, 100 silver equals one gold, and 1 gold equals 10,000 copper.
Going forward, I’ll refer to copper, silver, and gold collectively as just gold.
For special events, players could collect a rare currency such as gems. These gems would only be available for a limited time of course.
Now to get into the technical bits, we’ll define our conversion rates from Robux to copper as our base rate. Let’s start with 1 Robux equals 1 gold (10,000 copper coins). Also, new players will start with 0 gold and must complete quests to obtain gold as a reward.
local function totalSilver(copperBalance)
return math.floor(copperBalance / 100)
end
local function totalGold(copperBalance)
return math.floor(copperBalance / 10000)
end
We’ll use these functions to display each denomination in the player’s UI, i.e. 100 gold 50 silver 24 copper.
Next, we’ll create a bank service module script that uses Roblox data stores to manage data.
local DataStoreService = game:GetService("DataStoreService")
local BankServiceErrors = {
BalanceRetrievalError = 100,
DeductionError = 101,
DepositError = 102,
NoFundsError = 103,
UpdateBalanceError = 104
}
Our service will throw these errors for scripts to handle by using the pcall wrapper function.
In Lua, when a function calls error(), it will pass on the error information it’s given to the pcall wrapper function. So instead of having all your services handle every error scenario, the calling script can use the error data to handle each error.
As you’ll see in the BankService, when a script calls BankService:DeductPlayerBalance it’s possible that its internal function call to BankService:GetPlayerBalance will fail. In this case, we’ll get the BalanceRetrievalError enum and the function call will stop.
-- Bank Service Object
BankService = {}
-- BankService constructor
function BankService:new()
local o = {}
o.datastore = DataStoreService:GetDataStore("PlayerBanks")
self.__index = self
return setmetatable(o, self)
end
function BankService:GetPlayerBalance(playerId)
local balance = 0
-- get player bank from data store
local success, playerBank = pcall(function()
return self.datastore:GetAsync("player_bank_" .. playerId)
end)
if success then
balance = playerBank.Balance
else
error({ msg = "Error - failed to get bank balance for player:" .. playerId, code = BankServiceErrors.BalanceRetrievalError })
end
return balance
end
-- deduct player balance after purchase
function BankService:DeductPlayerBalance(playerId, cost)
local balance = self:GetPlayerBalance(playerId)
local success, playerBank = pcall(function()
return self.datastore:GetAsync("player_bank_" .. playerId)
end)
if success and balance > 0 then
balance = playerBank.Balance - cost
playerBank.Balance = balance
local withDrawalSuccess, withdrawalError = pcall(function()
return self:datastore:SetAsync("player_bank_" .. playerId, playerBank)
end)
if not withDrawalSuccess then
error({ msg = "Error - failed to deduct cost of " .. cost .. " for player " .. playerId, code = BankServiceErrors.DeductionError })
end
else
error({ msg = "player has a zero balance - " .. playerId, code = BankServiceErrors.NoFundsError })
end
return balance
end
-- update player balance with deposit
function BankService:DepositPlayerBalance(playerId, deposit)
local balance = self:GetPlayerBalance(playerId)
local success, playerBank = pcall(function()
return self.datastore:GetAsync("player_bank_" .. playerId)
end)
if success then
balance = playerBank.Balance + deposit
playerBank.Balance = balance
local depositSuccess, withdrawalError = pcall(function()
return self:datastore:SetAsync("player_bank_" .. playerId, playerBank)
end)
If not depositSuccess then
error({msg = "Failed to set updated balance from deposit for " .. playerId .. " deposit: " .. deposit, code = BankServiceErrors.UpdateBalanceError })
end
else
error({ msg = "Error - unable to deposit " .. deposit .. " for player " .. playerId, code = BankServiceErrors.DepositError })
end
return balance
end
We’ll use this BankService object whenever a player loots an item or purchases an in-game item with gold.
Notice that we’re using an object to store the player’s bank data with a Balance field. Next we’ll store each transaction that has affected the player’s bank balance.
Connecting game logic with Roblox game currency / money system
We can use these transactions to track the quests that a player has completed.
To accomplish transaction tracking, we’ll need a few other service modules.
As you can see in this diagram, we’ll use a QuestService, PlayerInventory, and PlayerQuestLog along with the BankService module.
The QuestService module will manage all logic of quest completion and tracking.
local DataStoreService = game:GetService("DataStoreService")
local QuestServiceErrors = {
QuestDataGetError = 200,
TurnInError = 201
}
These are the types of errors QuestService will throw for other scripts to manage.
-- QuestService Object
QuestService = {}
-- QuestService constructor
function QuestService:new()
local o = {}
o.playerInventory = PlayerInventory:new()
o.bankService = BankService:new()
o.playerQuestLog = PlayerQuestLog:new()
self.__index = self
return setmetatable(o, self)
end
-- private function to retrieve quest data
-- quest data contains all objectives, quest text, and item rewards
function QuestService:_getQuestData(questId)
local questsDataStore = DataStoreService:GetDataStore("Quests")
local success, questData = questsDataStore:GetAsync("Quests_" .. questId)
if success then
return questData
else
error(msg = "Error - unable to get quest:" .. questId, code = QuestServiceErrors.QuestDataGetError)
end
end
function QuestService:TurnInQuest(playerId, questId)
-- validate quest is complete
if self.playerQuestLog:isQuestComplete(playerId, questId) then
local questData = self:_getQuestData(questId)
-- add item rewards to player inventory
for i, item in ipairs(questData.RewardItems)
self.playerInventory:AddItem(playerId, item.id)
end
-- update player’s bank with currency reward
self.bankService:DepositPlayerBalance(playerId, questData.CurrencyReward)
else
error({ code = QuestServiceErrors.TurnInError, msg = "Error turning in quest:" .. questId .. ‘ for player:’ .. playerId })
end
end
We’ll use PlayerInventory to store all currently held items by the player. From here, we’ll inform the QuestService if we should fail quest completion when the player’s inventory is full.
local PlayerInventoryErrors = {
GetInventoryError = 300,
UpdateError = 301,
NoSpaceAvailableError = 302
}
-- PlayerInventory Object
PlayerInventory = {}
-- PlayerInventory constructor
function PlayerInventory:new()
local o = {}
o.datastore = DataStoreService:GetDataStore("PlayerInventory")
self.__index = self
return setmetatable(o, self)
end
-- inventory data model
-- inventory = { Items = {}, Total = 0, SpaceAvailable = 10 }
function PlayerInventory:GetInventory(playerId)
local success, inventory = pcall(function()
return self.datastore:GetAsync("inventory_" .. playerId)
end)
if success then
return inventory
else
error({msg = "Error getting inventory for player:" ..playerId, code = PlayerInventoryErrors.GetInventoryError})
end
end
function PlayerInventory:addItem(playerId, itemId)
-- validate inventory space is available
local inventory = self:GetInventory(playerId)
if inventory.SpaceAvailable > 0 then
table.insert(inventory.Items, itemId)
local success, updatedInventory = pcall(function()
self.datastore:UpdateAsync("inventory_" .. playerId, inventory)
end)
if not success then
error({ msg = "Error updating inventory for player:" .. playerId .. " action: adding item " .. itemId, PlayerInventoryErrors.UpdateError })
end
else
error({msg = "Error adding item to inventory for player:" .. playerId .. ", no space available", code = PlayerInventoryErrors.NoSpaceAvailableError })
end
end
function PlayerInventory:removeItem(playerId, itemId)
-- remove item from player’s inventory
local inventory = self:GetInventory(playerId)
inventory[itemId] = nil
end
PlayerQuestLog will store all completed quests. This is helpful for when we need to show the player’s quest progression.
-- PlayerQuestLog Object
PlayerQuestLog = {}
-- PlayerQuestLog constructor
function PlayerQuestLog:new()
local o = {}
o.datastore = DataStoreService:GetDataStore("PlayerQuestLog")
self.__index = self
return setmetatable(o, self)
end
function PlayerQuestLog:update(playerId, questId, updatedObjective)
local quests = self.datastore:GetAsync("player_quests_" .. playerId)
local quest = quests[questId]
if not quest.Completed then
-- update quest with updatedObjective
-- objective includes the total for required items collected
local questObjective = quest.Objectives[updatedObjective.id]
if questObjective.ItemsCollected == questObjective.ItemsRequired then
questObjective.Completed = true
end
-- mark quest complete when all objectives are done
local questIsdone = true
for i, objective in quest.Objectives
questIsDone = questIsDone and objective.Completed
end
If questIsDone then
quest.Completed = true
end
self.datastore:SetAsync("player_quests_" .. playerId, quest)
end
end
function PlayerQuestLog:removeQuest(playerId, questId)
-- remove quest from log
local quests = self.datastore:GetAsync("player_quests_" .. playerId)
quests[questId] = nil
end
function PlayerQuestLog:isQuestComplete(playerId, questId)
-- validate quest is complete and return boolean
local quests = self.datastore:GetAsync("player_quests_" .. playerId)
local quest = quests[questId]
return quest.Completed
end
All of these objects will throw errors for our scripts to handle.
local questService = QuestService:new()
local success, error = pcall(function()
questService:TurnInQuest(somePlayerId, someQuestId)
end)
if success then
-- send quest complete remote event
else
-- send remote event with error to update player’s UI with failure reason
end
Tracking currency transactions
We’ll use an ItemPurchaseService to track transactions affecting the player’s bank.
-- ItemPurchaseService Object
ItemPurchaseService = {}
-- ItemPurchaseService constructor
function ItemPurchaseService:new()
local o = {}
o.bankService = BankService:new()
o.playerInventory = PlayerInventory:new()
self.__index = self
return setmetatable(o, self)
end
function ItemPurchaseService:purchase(playerId, itemId)
-- validate available funds in player’s bank using BankService
local itemToPurchase = self.itemCatalog:get(itemId)
local balance = self.bankService:GetPlayerBalance(playerId)
-- deduct cost from player’s bank
self.bankService:DeductPlayerBalance(playerId, itemToPurchase.Cost)
-- add item to player’s inventory
self.playerInventory:addItem(playerId, itemId)
end
Here’s a script that uses ItemPurchaseService to complete and store the purchase transaction.
-- script to initiate item purchase
local itemPurchaseService = ItemPurchaseService:new()
local success, error = pcall(function()
itemPurchaseService:purchase(somePlayerId, someItemId)
end)
if success then
-- update UI with new item
else
-- update UI with failure reason
end
Create loot boxes
Loot boxes give players random rewards that keep bringing them back for more. We can also give a small portion of our in-game currency in our loot boxes to help fuel our game economy.
First, we’ll use a loot table that uses a weighted percentage (float values ranging from 0 to 1) threshold to determine the rewards of each box.
local lootTable = {
{
Item = "Currency",
Chance = 0.25,
MinAmount = 5,
MaxAmount = 20
},
{
Item = "PowerUp1",
Chance = 0.25,
MinAmount = 1,
MaxAmount = 2
},
{
Item = "PowerUp2",
Chance = 0.25,
MinAmount = 1,
MaxAmount = 2
},
{
Item = "PowerUp3",
Chance = 0.10,
MinAmount = 1,
MaxAmount = 2
},
{
Item = "PowerUp4",
Chance = 0.10,
MinAmount = 1,
MaxAmount = 2
},
{
Item = "RarePowerUp1",
Chance = 0.05,
MinAmount = 1,
MaxAmount = 1
}
}
local lootBox = {
totalItems = 3,
Items = {}
}
local function tableSize(table)
local tableSize = 0
for _ in pairs(table) do tableSize = tableSize + 1 end
return tableSize
end
-- function to deep clone a table object
local function deepCopy(orig)
local orig_type = type(orig)
local copy
if orig_type == 'table' then
copy = {}
for orig_key, orig_value in next, orig, nil do
copy[deepCopy(orig_key)] = deepCopy(orig_value)
end
setmetatable(copy, deepCopy(getmetatable(orig)))
else -- number, string, boolean, etc
copy = orig
end
return copy
end
Behind the scenes, Lua uses the C programming language’s random number generator. This function seeds the random number generator with the CPU’s run time (in seconds) so it doesn’t repeat its same random pattern each time.
You can use random.org’s API to get true random numbers.
local function updateRandomSeed()
local seed = os.clock() * 1000000000
math.randomseed(seed)
end
-- start - debugging functions
local function printTable(table)
if table then
for key, value in pairs(table) do
print(key, ':', value)
end
print('\n')
end
end
local function printList(list)
for index, value in ipairs(list) do
printTable(value)
end
end
-- end - debugging functions
local function buildLootBox(box)
local lootTableSize = tableSize(lootTable)
for i = 1, box.totalItems, 1 do
updateRandomSeed()
local itemWon = false
local rolls = 0
while not itemWon do
rolls = rolls + 1
local roll = math.random(0, 1000000)
local selectedItem = lootTable[roll % lootTableSize + 1]
local chance = math.floor(math.random() * 100) / 100
local chanceDiff = chance - selectedItem.Chance
if chanceDiff <= 0.01 and chanceDiff >= -0.01 then
itemWon = true
local clonedItem = deepCopy(selectedItem)
clonedItem.Total = math.random(selectedItem.MinAmount, selectedItem.MaxAmount)
table.insert(box.Items, clonedItem)
end
end
end
end
buildLootBox(lootBox)
printList(lootBox.Items)
The script will first select a random item from the loot table and then keep rolling until it meets the chance threshold of an item within a 1% tolerance. Once an item meets this threshold, we’ll randomize the amount given. The amount given ranges from its minimum and maximum amount for that item in the loot table.
You could update this even further so that certain types of items must get included such as in-game currency or a basic level powerup or bonus.
Different types of loot boxes could use a different mix in their loot table or use different chance thresholds as you see fit.
Attract, engage, and retain players
It’s important that the currency system you implement makes the game fun. Although so-called “play to win” games have been successful, players should not have to make real purchases to win or progress.
Reward new players early to give them a sense of progression.
If players can purchase powerful items, you should consider how that will affect new players that have just joined. No one wants to play a game that’s been around for a while if it’s overrun with veteran players that can easily dominate new players.
World of Warships is a good example of a game where you can purchase powerful items but you’re still matched against players with similar items.
Loot boxes should reward players for small wins and give them bonuses for achieving amazing feats in your game.
Consider how a game like Overwatch rewards its players with cosmetic items. Players can trade real money to buy more random loot or use your currency to get the items they really want.
What’s Next
Hope this has helped you see how you can connect different logic services to build a Roblox game currency system.
Please consider joining my email newsletter for more game development guides.
Thank you for reading, stay curious!