Build a banking script
This is the most advanced build in Start Here, and the safest. You will build a bank where the server holds every player's balance in a table, decides every deposit and withdraw, refuses any withdraw bigger than the balance, and writes the balance to disk so it is still there after a restart. By the end you type /bank balance, /bank deposit 100, /bank withdraw 40, and the numbers add up every time, even after you stop and start the server. This is the exact shape real money systems use. The only thing a finished server swaps in is a real database, which Track B teaches.
Watch a similar script
Why a bank is the hard one
A heal command gives you health. A teleport moves you. If a player cheats one of those, they ruined their own game and nobody else's. Money is different. Money is the one thing on a server that players will lie about, because money buys everything else. So the rule you already know, the server decides and the client obeys, stops being good advice here and becomes the whole reason the script exists.
Vocabulary
- balance
- How much money one player has. A single whole number per player. The server keeps it. The client is never told the raw number and never sends it.
- identifier
- A stable id that belongs to a player's account, not their seat in the lobby. We use their license. The same person gets the same balance every time they join, on any slot.
- KVS
- Key-Value Store. A tiny built-in storage box FiveM gives every resource for free. You write a value under a name and read it back later, even after a restart. No database to install.
- validate
- Check that an input is sane before you act on it. For money: is it actually a number, is it positive, is it whole. A bank that skips this is a bank that gets robbed.
The plan
Picture the server as a cashier with one private ledger. When the resource starts, the cashier reads the ledger off the disk into a table in memory, so every balance is loaded and ready. The table is keyed by each player's identifier, so each person's money is filed under their own name.
When a player types /bank withdraw 50, the request lands on the server. The cashier reads that player's row in the table, decides whether the withdraw is allowed (the amount must be a positive whole number, and it cannot be more than what they have), updates the number, then writes the whole ledger back to disk so it survives a restart. Finally the cashier tells that one player the result. The player never sends a balance and the server never trusts a number the player invented. The cashier owns the ledger. That is the entire lesson.
What happens behind the scenes
This whole build is one server file. The only thing that ever leaves the server is a chat line telling the player what happened. Here is the folder and the flow for a withdraw.
resources/qu_bank/
fxmanifest.lua
server.luaplayer types /bank withdraw 50 (in chat)
|
v
SERVER reads balances[license] from the table in memory
|
+-- amount not a positive whole number? --> refuse, tell player, stop
|
+-- amount > balance? --> refuse "not enough", tell player, stop
|
+-- ok? --> balances[license] = balance - 50 (update the table)
SetResourceKvp('balances', json.encode) (save to disk / KVS)
tell that one player the new balance (chat line)Read the flow top to bottom: the server reads the table, runs two checks, and only on a clean pass does it change the number, save it, and report back. Every refusal stops before the number is ever touched. The save happens the instant the number changes, so a restart can never lose it.
Build it
Follow the folder name, file names, and code exactly. Every line in this build runs on the server, except the chat line that notifies the player, and the code says which is which. Once it works, the next section takes it apart.
Make the qu_bank folder
Inside your server's resources folder, create this folder:
resources/qu_bankThen create two empty files inside it so the layout looks exactly like this:
resources/qu_bank/
fxmanifest.lua
server.luaTwo files, no client file. A bank has nothing for the client to do except hear the result, and chat handles that for us.
Write fxmanifest.lua
Open fxmanifest.lua and paste this:
fx_version 'cerulean'
game 'gta5'
server_script 'server.lua'server_script means this file runs on the server machine, the one place a player cannot edit. That single word is what makes the money safe. There is no client_script line because there is no client file. Do not add a lua54 'yes' line: as of June 2025 it is deprecated and ignored, because Lua 5.4 is the only runtime.
Load balances from the KVS on start
Open server.lua and paste this. Predict first: when you restart the resource, before you run any command, what do you think prints in the server console?
-- runs on the SERVER
local balances = {}
local function loadBalances()
local saved = GetResourceKvp('balances')
if saved then
balances = json.decode(saved)
end
print('[qu_bank] balances loaded')
end
loadBalances()Save the file. Open the txAdmin Live Console (the box at the bottom of the txAdmin web panel where you type server commands) and run:
ensure qu_bankNothing else happens yet, and that is correct. The first time you ever run this, the KVS is empty, so GetResourceKvp returns nothing, the if is skipped, and balances stays an empty table. The line prints because it sits at the top level of the file, not inside a command, so it runs once on start.
Add the /bank command with a balance check
Below the code you already have, add this. Predict: after you restart and run /bank balance in chat, what number does it show?
-- everything below runs on the SERVER
local function notify(src, message)
-- the ONLY thing that leaves the server: a chat line to one player.
-- chat:addMessage is a built-in chat event you trigger from the server to print one line into a player's chat box.
TriggerClientEvent('chat:addMessage', src, { args = { 'BANK', message } })
end
RegisterCommand('bank', function(src, args)
local id = GetPlayerIdentifierByType(src, 'license')
if not id then
notify(src, 'No license id found. Try rejoining.')
return
end
local action = args[1]
local balance = balances[id] or 0
if action == 'balance' then
notify(src, 'Your balance is ' .. balance)
return
end
notify(src, 'Use: /bank balance | deposit <n> | withdraw <n>')
end, false)Save. In the txAdmin Live Console run ensure qu_bank, then join your own server, wait until you have fully spawned in, open chat (press T by default), and type:
/bank balanceZero, because you have never deposited. balances[id] or 0 falls back to 0 when the player has no row yet, so a brand new player reads a clean zero instead of crashing on a nil.
Add deposit and withdraw with validation and saving
Replace the whole RegisterCommand('bank', ...) block from Step 4 with this fuller version. Predict: if your balance is 0 and you run /bank withdraw 50, what do you see, and does your balance change?
RegisterCommand('bank', function(src, args)
local id = GetPlayerIdentifierByType(src, 'license')
if not id then
notify(src, 'No license id found. Try rejoining.')
return
end
local action = args[1]
local balance = balances[id] or 0
if action == 'balance' then
notify(src, 'Your balance is ' .. balance)
return
end
-- validate the amount: must be a positive whole number
local amount = tonumber(args[2])
if not amount or amount <= 0 or amount % 1 ~= 0 then
notify(src, 'Amount must be a positive whole number.')
return
end
if action == 'deposit' then
balances[id] = balance + amount
elseif action == 'withdraw' then
if amount > balance then
notify(src, 'Not enough money. You have ' .. balance .. '.')
return
end
balances[id] = balance - amount
else
notify(src, 'Use: /bank balance | deposit <n> | withdraw <n>')
return
end
-- save to disk so the balance survives a restart
SetResourceKvp('balances', json.encode(balances))
notify(src, 'New balance: ' .. balances[id])
end, false)Save and run ensure qu_bank in the live console. Now in chat, run these in order:
/bank deposit 100
/bank withdraw 40
/bank withdraw 1000The deposit adds, the withdraw subtracts, and the overdraw is refused before the number is touched. Your /bank withdraw 50 prediction from a zero balance refuses the same way: the amount is fine, but 50 > 0, so it stops at the "not enough money" check and your balance stays 0.
Prove it survives a restart
Your balance is 60. Now restart the resource as if the server rebooted. In the txAdmin Live Console run:
restart qu_bankThen in chat, check your balance again:
/bank balanceThe restart wiped the balances table out of memory, then loadBalances() read it straight back off the disk from the KVS. The money is still 60. That is persistence: the number outlived the program that was holding it.
How it works
You ran it and the numbers held, even across a restart. Now take it apart so the next money system you build is a decision, not a copy. There are five pieces, and each one is load bearing.
The balances table and the identifier key
local balances = {}This is a Lua table used as a ledger. It lives on the very first line of server.lua, outside every command, on purpose: it has to hold every player's money for as long as the resource runs, so it cannot be rebuilt fresh on each call. The same lesson as the cooldown table in the heal capstone, applied to money.
local id = GetPlayerIdentifierByType(src, 'license')GetPlayerIdentifierByType is a server native, a function FiveM gives you for free, that returns one stable id for a connected player. We ask for the 'license' type, which is tied to the player's Rockstar account, not to their slot in the lobby. That matters. src (the server id, like 1 or 2) is just which seat they are sitting in this session, and it changes every time they reconnect. The license does not. So we key the ledger by license, which means the same person always reads the same balance, on any slot, on any reconnect. If we keyed by src instead, a player's money would vanish the moment they rejoined.
The if not id then ... return end guard runs because the console (server id 0) and a player who has not finished connecting can hand back a nil license. We refuse cleanly instead of crashing.
Validate the amount before you touch the money
local amount = tonumber(args[2])
if not amount or amount <= 0 or amount % 1 ~= 0 then
notify(src, 'Amount must be a positive whole number.')
return
endargs is the table of words the player typed after the command. For /bank deposit 100, args[1] is 'deposit' and args[2] is '100'. Everything in args arrives as text, even a number, so args[2] is the string '100', not the number 100. tonumber converts text to a number, and hands back nil if the text was not a number at all (like /bank deposit cat).
Then three checks, joined with or, any one of which refuses:
not amountcatches text that was not a number, becausetonumber('cat')isnil.amount <= 0catches zero and negatives. This one is the quiet exploit: without it,/bank withdraw -100would "withdraw" minus one hundred, which is the same as depositing a hundred you do not have. A bank that forgets to block negatives mints free money.amount % 1 ~= 0catches decimals.%is the remainder operator, so100 % 1is0(whole) and2.5 % 1is0.5(not whole). We keep money to whole numbers so nobody deposits0.000001ten million times to grind a glitch.
The rule to carry: never act on a number a player sent until you have proven it is the kind of number you expected. Validation is not paperwork, it is the lock on the vault.
Read, decide, write, in that order
local balance = balances[id] or 0
-- ... checks ...
balances[id] = balance + amount -- or balance - amountEvery action follows the same three beats. Read the current balance into a local (balance), falling back to 0 for a new player. Decide with the validation and the overdraw check. Write the new number back into the table only after every check passed. The overdraw check is the one that makes it a bank and not a wish:
if amount > balance then
notify(src, 'Not enough money. You have ' .. balance .. '.')
return
end
balances[id] = balance - amountThe return leaves the function the instant the withdraw is too big, so the line that subtracts never runs. Handle the bad case, leave, and let the good path be the rest of the function. Because the subtract happens only after the check, the balance can never go below zero. There is no path to a negative balance, because we close it off before we ever do the math.
Persistence with the KVS
SetResourceKvp('balances', json.encode(balances))A Lua table lives in memory, and memory is gone the moment the resource stops. To make the money survive a restart, we write it to disk. SetResourceKvp is a server native that saves a value under a name into the resource's own Key-Value Store, a tiny built-in storage box. But the KVS only stores plain text, and balances is a table, so we cannot hand it the table directly. json.encode turns the table into a single text string (something like {"license:abc":60}) that the KVS can hold. We save the moment the number changes, so there is never a window where the new balance exists only in memory.
local saved = GetResourceKvp('balances')
if saved then
balances = json.decode(saved)
endOn start, loadBalances() does the reverse. GetResourceKvp reads the text string back. json.decode turns that text back into a real Lua table, and we drop it into balances. The if saved guard handles the first-ever run, when nothing has been saved yet and GetResourceKvp returns nil: we skip the decode and keep the empty table. Encode on the way out, decode on the way in. That pair is how you put any table on disk.
Why the server owns the money
end, false)That trailing false is the restricted flag on RegisterCommand. Here it is false on purpose, because /bank is meant for ordinary players to run from their own chat, unlike the admin /heal you restricted with true. That is the exact reason every other line had to be so careful. A false command can be fired by anyone, so the safety cannot come from who is allowed to type it. The safety comes from the server checking everything.
And the server checks everything because the client is never trusted with money. Look at what the client never gets to do in this build: it never sends a balance (the server reads the balance from its own table), it never sends a new total (the server computes it), and it never decides whether a withdraw is allowed (the server runs the checks). The only thing that ever leaves the server is one chat line saying what happened, sent with TriggerClientEvent('chat:addMessage', ...). If a player edited their game to send a fake balance, there would be nothing to edit, because the client does not send a balance in the first place. The money lives on the server, the decision lives on the server, and the player only ever sees the result. That is server authority, and money is where it stops being a style choice and becomes the only safe way to build.
A player edits their game files to try to give themselves a million dollars through /bank. Why does it fail?
Because the client never sends a balance or a new total, so there is nothing on their machine to edit that the server would trust. The only things the player controls are the words they type (deposit, withdraw, and an amount), and the server validates every one of those: the amount must be a positive whole number, and a withdraw cannot exceed the balance the server itself is holding. The balance lives only in the server's table and on the server's disk. The player can ask, but the server decides and computes, so the most a player can do is send a request the server then refuses.
Common beginner mistakes
| Symptom | Fix |
|---|---|
A player edits their game and gives themselves any balance they want | You let the client send the balance or the new total. It never should. The server reads the balance from its own table and computes the new total. The client only sends the action and the amount, both of which the server then validates. |
/bank withdraw -100 makes a player richer | You skipped the amount <= 0 check. A negative withdraw subtracts a negative, which adds money. Validate that the amount is positive (amount > 0) before you act on it. |
/bank deposit 2.5 or /bank deposit abc breaks the math or errors | You did not convert and check the amount. Run tonumber(args[2]) first, refuse nil (not a number), and refuse decimals with amount % 1 ~= 0 so only whole numbers get through. |
A player withdraws more than they have and goes negative | You subtracted before checking. Put if amount > balance then return end above the line that subtracts, so the math only runs when the balance can cover it. |
Balances reset to zero every time the server restarts | You never saved, or you only saved once. Call SetResourceKvp('balances', json.encode(balances)) every time the balance changes, and call your load function on start so GetResourceKvp + json.decode read it back. |
attempt to index a nil value, or every player shares one balance | Your key is wrong. Use GetPlayerIdentifierByType(src, 'license') as the table key, not src. src is the lobby slot and changes on reconnect; the license is stable per account. Guard with if not id then return end for the console and half-connected players. |
If something breaks, reset to a known-good state: re-paste the exact fxmanifest.lua and server.lua from Build it, then in the txAdmin Live Console run refresh; restart qu_bank and try the commands again. refresh makes the server re-read your manifest and restart reloads the resource.
What you can do now
- Build a server-only resource where the server owns a table of player balances and the client is never trusted with a number.
- Key player data by a stable license identifier with GetPlayerIdentifierByType, not by the lobby slot src that changes on reconnect.
- Validate a player-sent amount before acting: convert with tonumber, then refuse non-numbers, zero or negative, and decimals.
- Run read, decide, write in order, and block an overdraw with an early return so a balance can never go below zero.
- Persist a Lua table across restarts with SetResourceKvp and GetResourceKvp plus json.encode and json.decode.
- Explain why money is the build where server authority stops being advice and becomes the only safe option.
Try it yourself
You just built the most careful resource in Start Here: a bank where the server owns every balance, refuses every bad amount, blocks every overdraw, and writes the money to disk so it survives a restart. Swap the KVS for a real database and this is a production money system. That is exactly what Track B does next. You are no longer someone who watches tutorials. You build resources, and you build them safely. You are ready for Track B.