START HERE·PRACTICE TEMPLATES·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 · Practice Templates

Seasonal event system (advanced)

This is not a tutorial you copy. It is a half-finished resource called qu_event that runs a seasonal giveaway, a winter drop, a launch-week bonus, any limited-time event you like. While the event is live, a player can claim a reward once. The scaffold wires the claim up but leaves the important decisions empty: it never checks the event is actually live, and it never checks whether this player already claimed. Your job is to write that logic on the server, where a cheater cannot touch it. A hardened solution is one click away when you want to check yourself.

Goal
A limited-time event that hands every player a one-time reward while it is active, decided and recorded on the server.
Difficulty
Advanced
Estimated time
~30 minutes
Skills practiced
server authority, config, time/date checks, claim tracking, events, the never-trust-the-client rule

Watch

RED: a time-gated reward, the same idea as a seasonal event.
BEFORE YOU START

The goal

You are finishing a small event resource named qu_event. It has three moving parts:

  • A config table, Config, that holds when the event runs (a start and end date, or a simple on/off flag) and what the reward is. This lives on the server and is the single source of truth for whether the event is live.
  • A client script that asks to claim the reward when the player runs /claim.
  • A server script that receives that request and is supposed to decide whether to grant the reward.

Right now the server grants the reward to anyone who asks, every time they ask. By the end you want this exact behaviour:

  • A player who claims while the event is live and has not claimed before gets the reward once.
  • A second claim by the same player is rejected. One reward per person.
  • A claim sent outside the event window is rejected, even if the player's game insists the event is on.

Vocabulary

Server authority
The rule that the server, not the player's game, has the final say on anything that matters: whether the event is live, who claimed, what they get. The client can ask, but the server decides.
Event window
The span of time the event is allowed to run, expressed as a start and end. Whether 'now' falls inside that window is a question only the server should answer, using the server's own clock.
Claim record
A record of which players have already claimed, so the same person cannot claim twice. Here it is an in-memory table keyed by a stable player identifier.

What you start with

Below is the resource as it ships. The claim flows end to end, but every decision that protects it is left as a -- TODO:. Read the comments. Nothing is hidden; the gaps are written right into the code so you can see exactly what is missing.

The manifest. Note there is no lua54 line: Lua 5.4 is the only runtime now, so that old setting is gone.

lua
-- fxmanifest.lua
fx_version 'cerulean'
game 'gta5'
 
shared_script 'config.lua'
client_script 'client.lua'
server_script 'server.lua'

The config. Set the event window and the reward here. You can gate the event two ways: a hard on/off flag, or a real date window. Both are shown so you can pick one.

lua
-- config.lua
Config = {}
 
-- Simple switch. Flip to false to turn the event off instantly.
Config.EventActive = true
 
-- Date window (used when you check by dates instead of the flag).
-- These are os.time values for the first and last moment the event is live.
-- os.time(table) turns a date into a number you can compare.
Config.EventStart = os.time({ year = 2026, month = 2, day = 1,  hour = 0,  min = 0,  sec = 0 })
Config.EventEnd   = os.time({ year = 2026, month = 2, day = 14, hour = 23, min = 59, sec = 59 })
 
-- What a player gets for claiming. A name here; granting it is your job.
Config.Reward = { item = 'event_token', amount = 1 }

The client. It only asks. It has no power to decide anything, and that is the point.

lua
-- client.lua
-- A real event UI would call this from a button. For the exercise we
-- expose it as a command so you can test by typing /claim.
RegisterCommand('claim', function()
    -- The client cannot be trusted to know if the event is live or if the
    -- player already claimed. It just asks the server and waits.
    TriggerServerEvent('qu_event:claimReward')
end, false)

The server. This is the part you will finish. Right now it hands out the reward to everyone, every time.

lua
-- server.lua
 
-- Placeholder reward function.
-- A real server would call its framework (ESX, QBCore, Qbox) or oxmysql here.
-- For the exercise this is a stand-in so you can focus on the decisions.
local function GiveReward(src)
    print(('[qu_event] gave %s x%d to player %d'):format(Config.Reward.item, Config.Reward.amount, src))
end
 
-- Remembers who already claimed, keyed by a stable player identifier.
-- NOTE: this lives in memory, so a server restart wipes it. A real event
-- persists claims in oxmysql so they survive a restart. That is out of
-- scope here and is your Track B next step.
local claimed = {}
 
RegisterNetEvent('qu_event:claimReward', function()
    local src = source -- the real player id, set by the server, not the client
 
    -- TODO: check the event is currently active. Do NOT trust the client to
    --       tell you this. Decide it here from Config and the server clock.
 
    -- TODO: build a stable identifier for this player, then check the claimed
    --       table. If they already claimed, reject and stop.
 
    -- TODO: grant the reward and record the claim so a second /claim is refused.
 
    -- Right now it just gives the reward to anyone, always. That is the bug.
    GiveReward(src)
    print(('[qu_event] %d claimed the reward'):format(src))
end)

Your job

Finish server.lua so a claim only succeeds when the server agrees. The client already does its whole job: it asks. Everything below happens server-side.

Checklist

  • Check the event is active on the server. Do not trust any client claim that it is. Decide it from Config: either read Config.EventActive, or compare os.time() against Config.EventStart and Config.EventEnd. If the event is not live, reject and stop.
  • Build a stable identifier for the player. A raw player id like 1 is reused when players reconnect, so it is a poor key for "who already claimed". Use something stable for this session and key the claimed table by it.
  • Check this player has not already claimed. If their identifier is already in claimed, reject and stop. One reward per person.
  • Grant the reward and record the claim. Call GiveReward(src), then mark the identifier as claimed so the next /claim is refused.
  • Reject politely on every failed check. A rejected claim should print a clear server-side line and give nothing, not error out.

The shape to aim for is the same as every server-authoritative feature: the client asks, the server checks, the server decides and records. The client never learns whether the event is live or whether it already claimed; it only finds out by asking.

Hints

If you are stuck, read these in order
  • The only safe place to decide "is the event live" is the server. With the flag, that is one line: if not Config.EventActive then return end. With dates, read the clock once, local now = os.time(), then if now < Config.EventStart or now > Config.EventEnd then return end. os.date('%Y-%m-%d', now) is handy if you want to print the current date while testing.
  • Key the claimed table by a stable player identifier, not the raw src. GetPlayerIdentifierByType(src, 'license') returns the player's Rockstar license, which stays the same across reconnects. Store the result in a local and use it as the key.
  • Return early on every failed check. Each guard is if (bad) then return end near the top of the handler. By the time you reach GiveReward, the event is live and this player has not claimed, so the grant is always safe.
  • Record the claim only after a successful grant, with claimed[identifier] = true (or os.time() if you want the timestamp). Set it before the grant and a failed grant still locks the player out; set it after and the order reads the way the logic flows.
  • A server restart clears an in-memory table, so every player could claim again after a restart. That is fine for practice. A real event persists the claim in a database so it survives restarts. Carry that to Track B: swap the claimed table for an oxmysql row per claim.

Reveal the solution

Stuck or done? Reveal a hardened solution

Here is the finished resource. The client is unchanged because it was already correct: it only asks. The server now decides whether the event is live, looks the player up by a stable identifier, refuses a second claim, and records the first one.

The config, unchanged from the scaffold. The window and reward live here, on the server.

lua
-- config.lua
Config = {}
 
Config.EventActive = true
 
Config.EventStart = os.time({ year = 2026, month = 2, day = 1,  hour = 0,  min = 0,  sec = 0 })
Config.EventEnd   = os.time({ year = 2026, month = 2, day = 14, hour = 23, min = 59, sec = 59 })
 
Config.Reward = { item = 'event_token', amount = 1 }

The client, unchanged. It asks and waits. It is given no authority, so there is nothing here for a cheater to flip.

lua
-- client.lua
RegisterCommand('claim', function()
    TriggerServerEvent('qu_event:claimReward')
end, false)

The server, finished. Every decision is made here, and every failed check returns early.

lua
-- server.lua (finished)
local function GiveReward(src)
    print(('[qu_event] gave %s x%d to player %d'):format(Config.Reward.item, Config.Reward.amount, src))
end
 
-- Who already claimed, keyed by a stable identifier. In memory only, so a
-- restart wipes it. A real event stores this in oxmysql (Track B).
local claimed = {}
 
-- Decide on the SERVER whether the event is live. Two strategies; use one.
local function isEventActive()
    -- Strategy A: a hard switch you can flip in config.
    if not Config.EventActive then
        return false
    end
 
    -- Strategy B: a real date window, judged by the server clock.
    local now = os.time()
    if now < Config.EventStart or now > Config.EventEnd then
        return false
    end
 
    return true
end
 
RegisterNetEvent('qu_event:claimReward', function()
    local src = source -- real player id, set by the server
 
    -- 1. Is the event live? Decided here, never trusting the client.
    if not isEventActive() then
        print(('[qu_event] %d claimed but the event is not active, rejected'):format(src))
        return
    end
 
    -- 2. A stable key for "who claimed". The raw src is reused on reconnect,
    --    so we key on the player's license, which stays the same.
    local identifier = GetPlayerIdentifierByType(src, 'license')
    if not identifier then
        print(('[qu_event] %d has no license identifier, rejected'):format(src))
        return
    end
 
    -- 3. Already claimed? One reward per person.
    if claimed[identifier] then
        print(('[qu_event] %d already claimed, rejected'):format(src))
        return
    end
 
    -- 4. Everything checked out. Grant the reward, then record the claim
    --    so the next /claim from this player is refused.
    GiveReward(src)
    claimed[identifier] = os.time()
    print(('[qu_event] %d claimed the reward (%s)'):format(src, os.date('%Y-%m-%d %H:%M', claimed[identifier])))
end)

Why both decisions must live server-side:

  • The window check belongs on the server because it is the only clock a cheater cannot wind. If the client decided "the event is on", a player would simply tell their game it is February forever and claim whenever they liked. Reading os.time() and comparing it to Config.EventStart/Config.EventEnd on the server means the answer comes from a clock the player never touches.
  • The claim record belongs on the server because it is the only memory a cheater cannot edit. If the client tracked "I already claimed", the player would just reset that flag and claim again. Keeping claimed in server memory (and, in production, in a database) means the second /claim hits a record the player has no way to clear.
  • Keying by a stable identifier stops a player from reconnecting to get a fresh id and claim again. The raw src changes; the license does not.
  • Returning early on every failed check means by the time you reach GiveReward, the event is live and this player has not claimed. The grant line only ever runs on a fully validated request.

What you practiced

  • Decided on the server whether an event is live, instead of trusting the client to say so.
  • Checked an event window with os.time and os.date against a server-owned start and end.
  • Keyed a claim record by a stable player identifier so a reconnect does not earn a second reward.
  • Refused a second claim by checking a server-side claimed table before granting anything.
  • Granted the reward and recorded the claim only after every check passed, using return-early guards.
  • Internalised the rule: both 'is it live' and 'did they claim' are server decisions, never client ones.

Nicely done. You just built the exact decision layer that separates an event a server runs once a year from one that gets farmed in an afternoon. The one thing left for production is memory that survives a restart: right now a reboot wipes the claimed table and everyone can claim again. Persisting each claim as an oxmysql row is a Track B job, and it slots straight into the spot where you set claimed[identifier]. You are ready for Track B. Go open it and turn this in-memory event into a database-backed one.