START HERE·BUILD REAL SCRIPTS·Verified June 2026 · Lua 5.4 · ox_lib 3.x
Learning with an AI assistant?
Copies this whole lesson - every step, code block, and the exact console errors - plus 2026 ground rules (no lua54 'yes', Cfx.re Portal, correct callback signatures) as a ready-to-paste mentor prompt.
Start Here · Build real scripts

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.

You'll build
One resource named qu_bank with a /bank command for balance, deposit, and withdraw, where the server owns every balance, a withdraw can never go below zero, and the money survives a server restart.
Time
~18 minutes
Difficulty
Beginner (capstone)
You need
Your local test server, a text editor (VS Code is free), and the teleport build finished so you already trust the server-decides rule.

Watch a similar script

Related: the same server-authority money pattern in a different script, an ATM robbery.
Related, not the exact code: a full banking system in action. A different script than the qu_bank you build here, shown so you can see the kind of thing you are building. The real output of your build is in the steps below.
BEFORE YOU START

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.

text
resources/qu_bank/
  fxmanifest.lua
  server.lua
text
player 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

The server has one folder for this resource.

Inside your server's resources folder, create this folder:

text
resources/qu_bank

Then create two empty files inside it so the layout looks exactly like this:

text
resources/qu_bank/
fxmanifest.lua
server.lua

Two 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

FiveM knows which file to load and where it runs.

Open fxmanifest.lua and paste this:

lua
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

The ledger is loaded into memory the moment the resource starts.

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?

lua
-- 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:

text
ensure qu_bank

Nothing 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

The command reads the action and reports the player's balance.

Below the code you already have, add this. Predict: after you restart and run /bank balance in chat, what number does it show?

lua
-- 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:

text
/bank balance

Zero, 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

The balance changes, refuses an overdraw, and is written to disk.

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?

lua
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:

text
/bank deposit 100
/bank withdraw 40
/bank withdraw 1000

The 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

The balance is still there after a full restart.

Your balance is 60. Now restart the resource as if the server rebooted. In the txAdmin Live Console run:

text
restart qu_bank

Then in chat, check your balance again:

text
/bank balance

The 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

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

lua
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

lua
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

args 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 amount catches text that was not a number, because tonumber('cat') is nil.
  • amount <= 0 catches zero and negatives. This one is the quiet exploit: without it, /bank withdraw -100 would "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 ~= 0 catches decimals. % is the remainder operator, so 100 % 1 is 0 (whole) and 2.5 % 1 is 0.5 (not whole). We keep money to whole numbers so nobody deposits 0.000001 ten 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

lua
local balance = balances[id] or 0
-- ... checks ...
balances[id] = balance + amount        -- or balance - amount

Every 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:

lua
if amount > balance then
    notify(src, 'Not enough money. You have ' .. balance .. '.')
    return
end
balances[id] = balance - amount

The 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

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

lua
local saved = GetResourceKvp('balances')
if saved then
    balances = json.decode(saved)
end

On 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

lua
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

SymptomFix
A player edits their game and gives themselves any balance they wantYou 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 richerYou 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 errorsYou 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 negativeYou 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 restartsYou 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 balanceYour 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.