Skip to main content
TRACK B·BUILD REAL PRODUCTS·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.

Jobs framework: clock-in, grades, paychecks

A job is a small state machine: off-duty or on-duty, plus a job name and grade that decide what the player may do. This lesson builds a framework-reading demo that keeps its own temporary duty table, gates one action by both job and grade, and calculates payroll on a timer. It prints the calculated pay; it does not change framework money or framework duty state.

You will learn
Duty state machine, framework detection, reading job and grade, a payroll loop, and an audit print.
Time
~35 min
Project
qu_jobs_framework
Cruze: building a job center script from zero.
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:

code
resources/qu_jobs_framework

Create the files

Every file named in the manifest exists.

Create this exact file layout:

code
resources/qu_jobs_framework/
fxmanifest.lua
server.lua

Write fxmanifest.lua

FiveM knows which files to load.

Open fxmanifest.lua and paste this:

code
fx_version 'cerulean'
game 'gta5'

server_script 'server.lua'

No lua54 'yes' line. As of June 2025 that directive is deprecated and ignored: Lua 5.4 is now the only Lua runtime, so you leave it out.

Write the lesson code

The topic is now represented by runnable code.

Open server.lua and paste this:

code
-- qu_jobs_framework: a framework-agnostic job spine.
-- Reads job + grade from whichever framework is running, toggles duty,
-- gates one grade-locked action, and pays players on a clock.

local duty = {}                 -- duty[src] = true|false, keyed by server id
local PAY_INTERVAL_MS = 60000   -- pay every 60 seconds while on duty
local BOSS_JOB = 'police'       -- the job allowed to use /jobaudit
local BOSS_GRADE = 2            -- minimum grade.level allowed to run /jobaudit

-- Return a normalised { name, grade, label } for the player on src,
-- regardless of which framework is installed. nil if no player is found.
local function getJob(src)
if GetResourceState('qbx_core') == 'started' then
    local player = exports.qbx_core:GetPlayer(src)
    if not player then return nil end
    local job = player.PlayerData.job
    return { name = job.name, grade = job.grade.level, label = job.grade.name }
elseif GetResourceState('qb-core') == 'started' then
    local QBCore = exports['qb-core']:GetCoreObject()
    local player = QBCore.Functions.GetPlayer(src)
    if not player then return nil end
    local job = player.PlayerData.job
    return { name = job.name, grade = job.grade.level, label = job.grade.name }
elseif GetResourceState('es_extended') == 'started' then
    local ESX = exports.es_extended:getSharedObject()
    local player = ESX.GetPlayerFromId(src)
    if not player then return nil end
    local job = player.getJob()
    return { name = job.name, grade = job.grade, label = job.grade_label }
end
return nil
end

RegisterCommand('clockin', function(src)
local job = getJob(src)
if not job then
    print(('[qu_jobs_framework] %s has no framework player (are you a loaded character?)'):format(GetPlayerName(src)))
    return
end

duty[src] = not duty[src]
print(('[qu_jobs_framework] %s job %s grade %d (%s) duty %s'):format(
    GetPlayerName(src), job.name, job.grade, job.label, tostring(duty[src])
))
end, false)

-- Grade-gated action: only a player at BOSS_GRADE or higher may run it.
RegisterCommand('jobaudit', function(src)
local job = getJob(src)
if not job then return end
if job.name ~= BOSS_JOB or job.grade < BOSS_GRADE then
    print(('[qu_jobs_framework] %s denied /jobaudit (%s grade %d)'):format(GetPlayerName(src), job.name, job.grade))
    return
end
print(('[qu_jobs_framework] %s ran /jobaudit as %s grade %d'):format(GetPlayerName(src), job.name, job.grade))
end, false)

-- Payroll loop: every interval, pay everyone currently on duty.
CreateThread(function()
while true do
    Wait(PAY_INTERVAL_MS)
    for src, onDuty in pairs(duty) do
        if onDuty then
            local job = getJob(src)
            if job then
                local pay = 50 + (job.grade * 25)   -- base + grade bonus
                print(('[qu_jobs_framework] payroll: %s (%s grade %d) earned $%d'):format(
                    GetPlayerName(src), job.name, job.grade, pay
                ))
            end
        end
    end
end
end)

-- Clear duty state when a player leaves, or the table leaks server ids forever.
AddEventHandler('playerDropped', function()
duty[source] = nil
end)

Start and test it

The expected proof appears in the correct console.

Open server.cfg and add this line below your framework's ensure line so the framework loads first:

code
ensure qu_jobs_framework

Save, then run:

code
restart qu_jobs_framework

Join the server with a loaded character, then run this from the F8 client console (these commands are server commands; the player's input is relayed to the server):

code
clockin

Read the result in txAdmin Live Console. On a typical default setup an unemployed character produces a line like this; your starting job and grade label can differ:

Leave the duty toggle on and wait one minute. The payroll loop fires and prints:

CLIENT
the player's game
network
SERVER
validate here
✓ trust boundary
The client requests clock-in; the server decides whether the job and grade allow it.

Keep reading the full lesson

Sign in to start, then unlock every step of this lesson and the full FiveM School with a membership.

Still ahead in this lesson
  • How it works
  • If something went wrong
  • What you can do now
  • Try it yourself

The remainder of Jobs framework is available to FiveM School members.