roblox currency

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.

roblox game currency bank system with transaction tracking

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.

roblox currency game logic

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

roblox item purchasing with currency / money

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!

Leave a Reply

%d bloggers like this: