Build a command from scratch
This is your first real build. From three empty files you will make a working /heal command: type it once and your character refills to full, type it again too soon and the server makes you wait. Nothing here is a toy. This is the exact shape real servers use for heal, give, and repair. By the end you will be able to start from an empty folder and reason about every line you typed, not just copy it.
Why a command exists
Before any code, the picture. A command is a word you type that runs a block of code on demand. Earlier modules taught you the pieces, a resource, a file, an event, the server-decides rule. A command is how you wire those pieces into something a person actually triggers.
You will reach for a command whenever you want to fire code by hand: an admin healing a stuck player, a builder testing one feature without waiting for the real game condition, a staff member spawning a vehicle. Heal, give, repair, teleport, kick, all of these are commands underneath. Build one cleanly and you have built the skeleton of all of them.
We are healing because it forces the one lesson that separates a hobby script from a real one: the server decides, the client obeys. Health lives on the player's own machine, but the rule about who may heal and how often cannot, or a cheater would just rewrite it. Watching that split appear in real code is the point of this build.
Vocabulary
- cooldown
- A minimum wait before an action is allowed again. Here, the time a player must wait between two heals. We store the moment of the last heal and compare it to now.
- os.time
- A built-in Lua function that returns the current time as a whole number of seconds. Subtracting two os.time readings tells you how many seconds passed between them.
- RegisterCommand
- A FiveM function that wires a typed command, like /heal, to a block of code. Its last argument is the restricted flag: true means only the server console and granted admins may run it.
- TriggerClientEvent
- A FiveM function the server uses to send a message to one specific player. The server uses it here to tell that player's client 'you are approved, heal now'.
- SetEntityHealth
- A client-side FiveM native that writes a health value onto a character (a ped). It only exists on the client, because the character lives on the player's own machine.
The plan
The key idea hiding in that plan is register now, run later. Think of RegisterCommand like pinning a recipe card to the fridge. Pinning it does not cook anything. The card sits there until someone walks up and says "make this," and only then does the cooking happen. The server pins the /heal recipe on startup and waits. Typing /heal is someone walking up to the fridge.
How the pieces connect
Here is the whole resource as a folder tree and the path one /heal takes across the network. Read the arrows before you write any code.
resources/qu_heal/
fxmanifest.lua lists the files and which side each runs on
server.lua owns the cooldown table and the yes/no decision
client.lua receives the approval and sets healthplayer types /heal (in chat)
|
v
SERVER reads its cooldown table
|
+-- too soon? --> yes --> print "denied", stop here
|
+-- ok? --> record os.time() in the table
TriggerClientEvent -> send "heal" to that one player
|
v
CLIENT receives "heal" --> SetEntityHealth(ped, 200) --> full healthThe shape to memorize: the server holds the rulebook and makes the decision, the client only applies the effect it was told to apply. The message crosses the network exactly once, from server to client, and only the server is ever trusted.
Build it
Follow the folder names, file names, and code exactly. We build in tiny steps, and on each step that runs code you predict first, then run, then compare what you saw. Once it works, the next section takes it apart line by line.
Make the qu_heal folder and three empty files
Inside your server's resources folder, create one folder and three empty files inside it so the layout looks exactly like this:
resources/qu_heal/
fxmanifest.lua
server.lua
client.luaThe qu_ prefix is the practice naming habit from earlier lessons, so your own scripts never clash with a downloaded one. Nothing runs yet. The files are empty. This step is just the skeleton.
Write fxmanifest.lua
Predict first. This file lists two scripts and tags each with a side. Before you read on, guess: which one will run on the server machine, and which one will run on every player's PC?
Open fxmanifest.lua and paste this:
fx_version 'cerulean'
game 'gta5'
server_script 'server.lua'
client_script 'client.lua'server_script runs on the server machine, the one program a player cannot edit. client_script runs on every player's own PC. That one distinction is what makes the rest of the lesson safe. Do not add a lua54 'yes' line: as of 2025 it is deprecated and ignored, because Lua 5.4 is now the only runtime. There is no console output to run yet. The manifest is read when the resource starts, which you will do in step 6.
Write server.lua
Predict first. This is the brain. Before you read the code, guess: when the resource starts, does anyone get healed right away, or does the file just register the command and then wait?
Open server.lua and paste this:
local cooldown = {}
RegisterCommand('heal', function(src)
local now = os.time()
if cooldown[src] and now - cooldown[src] < 30 then
local wait = 30 - (now - cooldown[src])
print('[qu_heal] denied, ' .. src .. ' must wait ' .. wait .. 's')
return
end
cooldown[src] = now
TriggerClientEvent('qu_heal:apply', src)
print('[qu_heal] approved heal for ' .. GetPlayerName(src))
end, true)The cooldown table sits at the very top of the file, outside the command, on purpose. The true at the end is the restricted flag. Both choices get explained in Read it back. Notice that every function is paired with an end: the end, true) line closes the handler body, then closes RegisterCommand. A missing end is the most common Lua error a beginner hits, so train your eye to pair them now.
Write client.lua
Predict first. This is the hands. Before you read it, guess: where does SetEntityHealth have to live, the server file or the client file, given that the character only exists on the player's machine?
Open client.lua and paste this:
RegisterNetEvent('qu_heal:apply', function()
local ped = PlayerPedId()
SetEntityHealth(ped, 200)
print('[qu_heal] healed to full')
end)RegisterNetEvent is required here because this event arrives from across the network. Without it the message is rejected and nothing heals. Still no output to run: this code only fires when the server sends qu_heal:apply, which happens in step 6.
Ensure it in server.cfg
Open server.cfg and add this line:
ensure qu_healSave the file. This line tells the server to start qu_heal on boot. On its own it does nothing until the next boot, which is why the next step starts the resource by hand.
Start the resource and run /heal
Predict first. You are about to type /heal once, then immediately again. Guess two things: what shows up the first time, and what shows up the second time within 30 seconds.
Open the txAdmin Live Console (the box at the bottom of the txAdmin web panel where you type server commands). Type this and press Enter:
ensure qu_healUse ensure, not restart, the first time. The server.cfg line is only read when the whole server boots, so on an already-running server the resource has not started yet. ensure starts it if it is stopped and restarts it if it is already running, so it always works.
Now join your own server, wait until your character has fully spawned in, open the in-game chat box (press the chat key, by default T), type the command, and press Enter:
/healWhat you just saw. The server line lands in the txAdmin Live Console, and the client line lands in your F8 console (press F8 in game). Your health bar tops off:
Now run /heal again immediately, before 30 seconds pass. The server refuses and no heal is sent, so the F8 console stays silent this time:
If your prediction was that the first /heal heals and the second is refused, you read the plan correctly. If you expected the heal line to appear in chat or on screen, that is the lesson landing: print writes to a console, never to the player's screen.
Read it back
You ran it, and you saw the heal fire once and the refusal fire on the retry. Now take it apart so next time you are deciding, not copying. We will connect each piece back to what you saw in the console.
The cooldown table and the os.time math
local cooldown = {}This is a Lua table, used here as a lookup list. The key is a player's server id, the value is the moment that player last healed. It starts empty because nobody has healed yet. It lives on the first line of server.lua, outside the command, which matters a lot. We return to that in the mistakes section.
local now = os.time()
if cooldown[src] and now - cooldown[src] < 30 thenos.time() returns the current time as a whole number of seconds. cooldown[src] is the reading from this player's last heal, or nil if they have never healed. The if reads in plain English as: if this player has healed before, and fewer than 30 seconds have passed since then, refuse. The cooldown[src] and part guards the first-ever heal: if the value is nil, the whole condition is false and the heal is allowed. That is why your first /heal went through. Subtracting two os.time readings is the entire trick behind every cooldown you will ever write.
local wait = 30 - (now - cooldown[src])
print('[qu_heal] denied, ' .. src .. ' must wait ' .. wait .. 's')
returnThis is the refusal path, the one you saw on your second /heal. now - cooldown[src] is how many seconds have already passed, so 30 - that is how many seconds are left. We print it and then return, which stops the function right there. Because of that return, the lines below it never run, so on a refusal no time is recorded and no heal is sent. That early return is the cleanest way to write an if/else: handle the bad case, leave, and let the good case be the rest of the function.
cooldown[src] = now
TriggerClientEvent('qu_heal:apply', src)
print('[qu_heal] approved heal for ' .. GetPlayerName(src))If the code reaches here, the request passed, which is the path your first /heal took. It stamps now into cooldown[src], so the next attempt is measured from this moment, then sends the approval to the one player. The .. operator joins strings end to end, so the print reads like a sentence.
Why the heal is triggered to the client
TriggerClientEvent('qu_heal:apply', src)The server cannot heal the player directly. Health lives on the character, the ped, and the ped is a client-side entity that only exists on the player's own PC. So the server sends a message instead. TriggerClientEvent takes the event name and a target, here src, the id of the one player who asked. The message travels only to that player, not to everyone.
RegisterNetEvent('qu_heal:apply', function()
local ped = PlayerPedId()
SetEntityHealth(ped, 200)
print('[qu_heal] healed to full')
end)On the client, RegisterNetEvent listens for that approval. PlayerPedId() returns the handle of your character, the body the camera follows. SetEntityHealth(ped, 200) writes the health value onto it. Both of these are client natives: they only exist on the client, because that is where the ped is. This is the server-decides, client-acts rule made real. The server said yes; the client did the work, which is the [qu_heal] healed to full line you saw in F8.
Why 200 and not 100? In GTA V, a player ped's maximum health is 200, not 100. The number you see as a health bar in many UIs is scaled, but the real value the engine uses tops out at 200. Passing 100 would only half-heal the player. This is the kind of fact that trips up everyone once, so lock it in now: full health for a player ped is 200.
Why the command is restricted and the server owns the cooldown
end, true)That trailing true is the restricted flag on RegisterCommand. With true, only the server console and identities you grant an ace permission may run the command. The server console always bypasses the restriction, which is why you can also test from the txAdmin Live Console. If you set it to false, any connected player could fire the command from their chat box, and unauthenticated player-triggered server actions are the single most common way FiveM servers get exploited. For anything that changes game state, true is the safe default.
The cooldown table lives in server.lua for the same reason the decision does. The server is the one program a player cannot edit. If the cooldown lived on the client, a player running a cheat menu could reset their own list and heal as fast as they liked. Keeping the rulebook on the server means the answer is real.
Why does nothing get healed the instant the resource starts, only when you type /heal?
Because the heal lives inside the command handler, not at the top level of the file. On ensure, the server reads server.lua once and reaches RegisterCommand, which only pins the command and stores the handler for later, the register-now, run-later idea. The body, including the TriggerClientEvent that triggers the heal, runs only when /heal is actually typed. That gap between registering and running is the whole idea behind event-driven code, and almost everything you build in FiveM works this way.
Why does the server hold the cooldown table instead of the client?
Because the client runs on the player's own machine, and the player can edit anything on it. A cooldown stored on the client could be reset or faked by a cheat menu, so it would enforce nothing. The server is the one program the player cannot tamper with, so a rule kept there, like this 30 second cooldown, gives a real yes or no. The general rule: any decision a player should not be able to control lives on the server, and only the visible effect lives on the client.
The refused player sees nothing in game yet
Right now a denied heal only prints to the server console. The player who typed /heal sees nothing in their own game, which is confusing. In a finished resource you would send a denial event back to that player, exactly like the approval, and show it as a chat message or a notification. The Try it yourself exercise and Track B both build toward that. For a first build, a server print is enough to confirm the cooldown path fired.
If something went wrong
Stuck? Reset to a known-good state first. Re-paste the exact fxmanifest.lua, server.lua, and client.lua from Build it, overwriting whatever you have, then in the txAdmin Live Console run refresh; restart qu_heal and try /heal again. refresh makes the server re-read your files, and restart reloads the resource. A guaranteed way back to working beats a debugging spiral. Then check your symptom below.
| Symptom | Fix |
|---|---|
attempt to call a nil value (global 'SetEntityHealth') | You put SetEntityHealth in server.lua. It is a client native and only exists on the client. Move it into client.lua, inside the RegisterNetEvent handler, and let the server only send the approval. |
attempt to call a nil value (global 'RegisterCommand') | The native name is misspelled. It is RegisterCommand with a capital R and a capital C. Lua is case sensitive, so registercommand will not resolve. |
'end' expected (near <eof>) | A function has no closing end, or the final end before the true flag got deleted. Every function must finish with end. Here the handler ends with the line end, true) which closes the function, then closes RegisterCommand. |
The server prints 'approved heal' but the player never refills and F8 shows nothing | The client is missing RegisterNetEvent for qu_heal:apply, or the event name does not match on both sides. A networked event is rejected unless its exact name was registered with RegisterNetEvent on the receiving side. Compare the two strings character for character. |
The cooldown never blocks: you can /heal again instantly every time | The cooldown table was declared inside the command handler (local cooldown = {} below RegisterCommand), so it is rebuilt empty on every call and forgets the last heal. Move local cooldown = {} to the top of server.lua, outside the handler, so it persists between calls. |
Health only fills to half a bar | You passed 100 to SetEntityHealth. A player ped's max health is 200, not 100. Use SetEntityHealth(ped, 200) for a full heal. |
A normal player runs /heal in chat and nothing happens | That is the restricted flag (true) working as intended: the command does nothing for an unprivileged player because it requires an ace permission. Run /heal from the txAdmin Live Console, which bypasses the restriction, or grant the player an ace in server.cfg with add_ace, then ensure qu_heal. |
What you can do now
- Build a complete resource from an empty folder: a folder, an fxmanifest.lua that lists the files and their sides, a server file, and a client file.
- Register a restricted command with RegisterCommand and read the trusted src argument FiveM hands you.
- Explain register-now, run-later: the server pins the command on startup and only runs the handler when /heal is typed.
- Enforce a per-player cooldown with a server-side Lua table and os.time, using an if and an early return as your decision.
- Send an approval to one specific player with TriggerClientEvent, and apply the effect on the client with PlayerPedId and SetEntityHealth.
- Explain the server-decides, client-acts rule, why health natives must run client side, and why a player ped's full health is 200.
Try it yourself
You just built a real resource from three empty files: the manifest, a restricted command, an if/else decision, a table cooldown, and the server-authority rule, the same spine behind heal, give, repair, and shop commands on real servers. You are no longer someone who watches tutorials. You build commands. Next you will reuse this exact shape to move a player instead of healing one, in Build a teleport system.