Skip to main content
TRACK B·LUA FUNDAMENTALS·Verified June 2026 · Lua 5.4 · ox_lib 3.x
Learning with an AI assistant?
Copies this lesson plus 2026 ground rules (no lua54 'yes', Cfx.re Portal, correct callback signatures) as a ready-to-paste mentor prompt.

Loops: for, while, and when to stop

If you copy-paste the same line over and over, you probably need a loop. A loop runs a block of code again and again until you stop it. In FiveM, knowing when to stop matters more than anywhere else you have coded, because a loop that never yields does not just slow down, it freezes the entire server thread. By the end you will know for loops, while loops, break, and the one habit, Citizen.Wait, that prevents your first crash.

You'll build
A qu_loops resource with three commands: a numeric for countdown, a while countdown, and a safe ticking thread that uses Citizen.Wait.
Time
~22 minutes
You need
Lessons 01-03 done. A working FiveM server you can restart.
You'll learn
Numeric for -> step values -> while loops -> break -> off-by-one errors -> the FiveM Wait rule that stops your first server freeze
BEFORE YOU START

Build it

Make the resource folder

The server has one folder for this lesson.

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

text
resources/qu_loops

Create the files

Every file named in the manifest exists.

Create this exact file layout:

text
resources/qu_loops/
fxmanifest.lua
server.lua

Write fxmanifest.lua

FiveM knows which files to load.

Open fxmanifest.lua and paste this:

lua
fx_version 'cerulean'
game 'gta5'
 
server_script 'server.lua'

No lua54 'yes' line. As of June 2025 the FiveM docs mark that directive as deprecated: Lua 5.3 was removed and every resource now runs Lua 5.4, so the directive has no effect, so omit it. (See the official manifest reference and the "Removal of Lua 5.3 support" announcement.)

Write the lesson code

The topic is now represented by runnable code.

Open server.lua and paste this:

lua
-- 1. Numeric for: counts 5, 4, 3, 2, 1 then launches.
RegisterCommand('countdown', function()
for i = 5, 1, -1 do
    print('[qu_loops] ' .. i)
end
print('[qu_loops] Launch!')
end, true)
 
-- 2. while + break: stops early the first time it hits 3.
RegisterCommand('whilecount', function()
local i = 5
while i > 0 do
    if i == 3 then
        print('[qu_loops] hit 3, stopping early')
        break
    end
    print('[qu_loops] while ' .. i)
    i = i - 1
end
end, true)
 
-- 3. A ticking thread that YIELDS. Citizen.Wait stops it freezing the server.
RegisterCommand('tick', function()
Citizen.CreateThread(function()
    for tick = 1, 3 do
        print('[qu_loops] tick ' .. tick)
        Citizen.Wait(1000) -- yield 1 second between ticks
    end
    print('[qu_loops] thread done')
end)
end, true)

Start and test it

The expected proof appears in the correct console.

Open server.cfg and add this line:

text
ensure qu_loops

Save. Then, in the txAdmin Live Console (the same place you will type the test commands below, or the FXServer console window if you run it directly), type:

text
restart qu_loops

Run this test in the txAdmin Live Console:

text
countdown

Then run whilecount. You should see exactly:

text
[qu_loops] while 5
[qu_loops] while 4
[qu_loops] hit 3, stopping early

Then run tick. You should see these lines arrive one per second, not all at once, because the thread yields between each print:

text
[qu_loops] tick 1
[qu_loops] tick 2
[qu_loops] tick 3
[qu_loops] thread done

How it works

You ran it and the numbers fell. Now take the loops apart so the next time you write one you are deciding, not copying. Three commands, three loop shapes, and one rule that is unique to FiveM and will eventually save your server.

The numeric for header: start, stop, step

lua
for i = 5, 1, -1 do
    print('[qu_loops] ' .. i)
end

A numeric for is the loop you reach for when you know exactly how many times to run. The header has three numbers separated by commas, and reading them in order is the whole trick:

  • 5 is the start. The counter variable i begins at 5.
  • 1 is the stop. The loop keeps running as long as i has not passed 1. Lua includes the stop value, so 1 itself does run. This is an inclusive range, which is why you see five lines, not four.
  • -1 is the step. After each pass, Lua adds the step to i. A negative step counts down, so i goes 5, 4, 3, 2, 1. Leave the step out, like for i = 1, 5 do, and Lua assumes +1, counting up.

The variable i only exists inside the loop. You do not declare it with local and you do not change it by hand. Lua owns the counter and moves it for you. That is the difference between a for and a while: a for manages its own counter, a while does not. After the five numbers print, the loop is finished and control falls through to the print('[qu_loops] Launch!') line, which sits outside the loop body and runs exactly once.

The while loop: you own the counter, you own the off-by-one

lua
local i = 5
while i > 0 do
    print('[qu_loops] while ' .. i)
    i = i - 1
end

A while loop runs as long as its condition is true. Here the condition is i > 0. Before each pass Lua checks it. While it holds, the body runs. The moment it is false, the loop stops and the program moves on.

Nothing moves the counter for you here. You set i = 5 yourself, and the line i = i - 1 is what makes the loop end. Delete that line and i stays 5 forever, the condition i > 0 is always true, and you have an infinite loop. On the client that stutters the game. On the server, as you will see in a moment, it freezes everything.

This is also where off-by-one errors live. The condition i > 0 means the loop runs for 5, 4, 3, 2, 1 and stops before printing 0. If you had written i >= 0 it would also print 0, one extra pass you did not want. If you decremented before printing instead of after, you would skip 5 and start at 4. The fix is always the same: read the condition out loud and count the first and last value by hand. A while gives you full control, and full control means the bug is yours to make.

break: stopping a loop early

lua
while i > 0 do
    if i == 3 then
        print('[qu_loops] hit 3, stopping early')
        break
    end
    print('[qu_loops] while ' .. i)
    i = i - 1
end

Sometimes you want out before the condition naturally goes false. break does exactly that: it stops the nearest loop immediately and jumps to the first line after it. In the whilecount command, the loop is built to run from 5 down to 1, but the if i == 3 check fires break on the third pass, so you only ever see while 5, while 4, then the stop message. The numbers 2 and 1 never print. break works the same inside a for loop. It is how you write loops that scan for something and quit the instant they find it, instead of grinding through every remaining item.

repeat...until: the loop that always runs once

There is a third loop shape the countdown and whilecount commands did not use, and it is worth knowing because it flips one thing around. A while checks its condition before the first pass, so if the condition is false from the start, the body never runs at all. A repeat...until checks after each pass, so the body always runs at least once before the condition is even looked at.

lua
local counter = 0
repeat
    print('[qu_loops] pass ' .. counter)
    counter = counter + 1
until counter == 5

Read it backwards from a while and it clicks. A while says "keep going while this is true." A repeat...until says "keep going until this becomes true" -- the condition is the stop sign, not the green light. This loop prints passes 0, 1, 2, 3, 4 and stops the moment counter reaches 5.

The one practical reason to reach for it: setup work that must happen once no matter what, then keep happening while a condition holds. Think of waiting for something to load -- you want to check at least once even if the thing might already be ready. For everything else, prefer while, because while makes the "might run zero times" case obvious to the next person reading your code. Like every loop here, a repeat...until that lives in a Citizen.CreateThread and could spin many times still needs a Citizen.Wait inside it -- the same rule you are about to meet applies to all three shapes.

The FiveM Wait rule: the loop that freezes your server

This is the part of the lesson nothing else teaches you. A loop that never pauses is fine in plain Lua. In FiveM it is a weapon pointed at your own server.

FiveM runs your script on a shared thread. When your code is running, nothing else on that side gets a turn: not other resources, not player connections, not the next frame. A normal loop like the countdown finishes in microseconds and hands the thread back, so you never notice. But a loop that runs forever never hands it back. This is the broken version, do not paste it into a live server:

lua
-- BROKEN: never yields. Freezes the entire server thread.
Citizen.CreateThread(function()
    while true do
        print('[qu_loops] spinning')
    end
end)

while true is always true, so this loop never ends on its own, and there is no break. It prints as fast as the CPU allows and never gives the thread back. On the server that means every player times out and the whole server appears to hang. The fix is one line, the tick command you already built:

lua
Citizen.CreateThread(function()
    for tick = 1, 3 do
        print('[qu_loops] tick ' .. tick)
        Citizen.Wait(1000) -- hand the thread back for 1 second
    end
end)

Citizen.Wait(ms) is the yield. It pauses your loop for the given milliseconds and, crucially, hands the thread back to FiveM so everything else can run. After the wait, your loop picks up where it left off. Note: most FiveM code you will read writes this as just Wait(ms) -- Wait is the built-in short alias for Citizen.Wait, so the two are interchangeable. The rule is absolute: any loop that could run forever must call Citizen.Wait somewhere inside it. Citizen.CreateThread is what lets a loop keep living after the command handler returns, which is exactly why an unyielded one is so dangerous, it would spin forever with no command to stop it.

Why server loops and client loops are not the same

On the client, the loop you write most often is a per-frame loop with Citizen.Wait(0), which yields for the shortest possible time and resumes next frame. That is how you draw a marker or watch for a key press sixty times a second. The cost is paid on one player's machine.

On the server, Citizen.Wait(0) resumes on the next server tick. The cost depends on the work in the loop body, but a tick-rate poll is unnecessary for most database, economy, and lifecycle logic. Choose an interval based on the feature and prefer events when work only needs to happen after a specific action. Use a for for a known list, a while with a real exit condition, and an always-running thread only when a periodic task is actually required.

Dynamic sleep: the optimization every real client loop uses

The previous section said the cost of a loop depends on how often it wakes up. Dynamic sleep is how experienced scripters control that cost on the client. The idea: do not pick one fixed Citizen.Wait. Pick a slow wait when nothing interesting is happening, and only drop to a fast wait when the player is somewhere the loop actually needs to react.

Here is the pattern, a client loop that watches the player's distance to a fixed point. This is client code, so it belongs in a client_script, and its prints land in the F8 client console, not the server console:

lua
Citizen.CreateThread(function()
    while true do
        local sleep = 1000 -- idle default: only check once a second
        local ped = PlayerPedId()
        local coords = GetEntityCoords(ped)
        local target = vector3(100.0, 200.0, 20.0)
 
        if #(coords - target) < 10.0 then
            -- player is close, react quickly this frame range
            sleep = 0
            print('[qu_loops] near target')
        end
 
        Citizen.Wait(sleep)
    end
end)

Read the flow and the why falls out. sleep starts at 1000, so when the player is nowhere near the target the loop wakes only once a second and costs almost nothing. PlayerPedId() returns the local player's character ped, and GetEntityCoords(ped) returns its current position as a vector3. #(coords - target) is the FiveM idiom for distance: subtracting two vector3s gives a vector, and the # length operator turns that into the straight-line distance in metres -- so < 10.0 reads as "within ten metres." Only then does the code set sleep = 0, which means "resume next frame," giving you up to sixty checks a second exactly when the player is close enough to need them. The single Citizen.Wait(sleep) at the bottom of the loop uses whichever value the pass decided on.

The mistake this pattern fixes is the lazy Citizen.Wait(0) everywhere. A loop that runs every frame all the time, just to check a distance the player is nowhere near, burns client frames for nothing -- and a server full of resources each doing that is how a server quietly loses FPS. Idle slow, active fast: that one habit is the difference between a script that scales and one that drags. Note this is a client trick -- on the server the same while true still must yield, but you would tune the interval to the feature (a database poll measured in seconds, an event-driven check ideally never polling at all) rather than chasing frames.

What the restricted flag actually does

lua
RegisterCommand('countdown', function() ... end, true)

That trailing true is the restricted flag, the same one you have used before, and it is worth naming clearly here because it is a silent gotcha. With true, the command requires an ace permission to run. The server console has full rights, so countdown works the instant you type it there. But a connected player typing /countdown in their chat box has no such permission by default, so for them the command does nothing and gives no error. That silence is the trap: the command is not broken, it is gated. If you wanted any player to run it, you would pass false instead, and to grant a specific admin the right while keeping true, you would add an ace rule in server.cfg. For a server-side proof you do not want random players firing, true is the safe default.

You write a Citizen.CreateThread with while true inside and no Citizen.Wait, then restart the resource. What happens, and what is the one-line fix?

The loop never yields, so it holds the shared server thread forever. Other resources stop responding, player connections time out, and the server appears frozen, it does not crash with a clean error, it just hangs. The fix is to add a Citizen.Wait(ms) call inside the loop so it hands the thread back on every pass. Any loop that can run forever, especially a while true, must yield with Citizen.Wait. A finite loop like the for countdown does not need a Wait because it ends on its own in microseconds.

If something went wrong

SymptomFix
Resource qu_loops does not existCheck the folder is inside resources and the ensure line uses the exact same name.
The server hangs or all players time out after restartA loop is missing its Citizen.Wait. Find any while true or unbounded while and add Citizen.Wait(ms) inside it, then restart qu_loops.
The loop prints one too many or one too few linesOff-by-one. Read the for range or the while condition and count the first and last value by hand. Remember a for stop value is inclusive and the default step is +1.
whilecount prints forever and never stopsThe counter is not changing. Make sure i = i - 1 is inside the while body so the condition eventually goes false.
A player typing /countdown in chat sees nothing happenThe restricted flag is true, so only the server console and aces may run it. Run countdown from the txAdmin Live Console, or pass false to RegisterCommand to open it to players.
A repeat...until loop runs forever and never stopsA repeat checks its condition only AFTER each pass, so if the counter or flag inside never changes, the until line is never satisfied. Make sure the value the until tests is updated inside the loop body, and remember until names the STOP condition, not the continue condition.

What you can do now

  • Read a numeric for header as start, stop, step, and know the stop value is inclusive and the default step is +1.
  • Write a while loop, move its counter yourself, and spot an off-by-one by counting the first and last pass.
  • Use break to leave a loop the instant a condition is met instead of running every pass.
  • State the FiveM Wait rule: any loop that can run forever must call Citizen.Wait, or it freezes the whole server thread.
  • Explain why a server-side Citizen.Wait(0) is far costlier than the client per-frame version, and why the restricted flag silently blocks players.

Run it live

No server needed for this part. Edit the Lua below and press RUN to execute it in your browser on real Lua 5.4. This is the same countdown from the countdown command, the for i = 5, 1, -1 you just dissected. The sandbox has no FiveM around it, so Citizen.Wait, Citizen.CreateThread, and RegisterCommand are not available here. The plain for loop, print, and string joining run exactly as they would on your server.

sandbox.luaLUA 5.4
OUTPUT
Press RUN to execute.

Try it yourself