Get featured

How to make a RedM script with AI

Guide · checked against the docs on

RedM scripting looks a lot like FiveM scripting, and that's exactly where AI tools trip up. Most of the Lua they've seen online was written for GTA V, so they'll happily hand you a FiveM manifest, a GTA native or a framework call that doesn't exist on RedM. The code looks right, and it won't even start.

This guide builds one small RedM resource with an AI tool open beside the editor. The loop is the same every time: ask for one piece, read it, check it against the docs, lint it, test it, then try to break it.

Not affiliated with or endorsed by Rockstar Games, Take-Two or Cfx.re. If this guide and the Cfx.re docs ever disagree, trust the docs.

What you'll build

A notice board for towns. A player types /notice valentine Selling a saddle, ask at the stable and the server pins it to the Valentine board. Anyone can type /board valentine to read the five newest notices.

It's small, but it has the parts that go wrong in real scripts: free text typed by a player, a value that has to come from a fixed list, a server event that strangers can call, and data that belongs to a player who will eventually leave.

You need:

Step 1: Say "RedM" loudly in the prompt

If you just say "a Lua script for my server", a lot of tools will assume FiveM. Name the game, the manifest lines and the security rules before it writes anything:

I'm writing a standalone RedM resource (Red Dead Redemption 2, game 'rdr3') in Lua.
No framework: no VORP, no RSG, no FiveM/GTA code.
Goal: a town notice board.
  /notice <town> <message>  posts a notice
  /board <town>             shows the 5 newest notices
Rules:
- The client only sends the request. The server checks and stores.
- On the server, use `source` for the author. Never accept a name or ID from the client.
- Check the type of every argument and reject anything over 500 bytes before cleaning.
- Keep the town on a server-owned list.
- Strip chat colour codes (^0 to ^9) and control characters, reject invalid UTF-8,
  and cap the message at 80 characters (characters, not bytes).
- Add a per-player cooldown on the server event.
- On playerDropped, clear that player's cooldown and remove their notices.
- Manifest: fx_version 'cerulean', game 'rdr3' and the exact rdr3_warning line.
- Give me fxmanifest.lua, client.lua and server.lua, and list every native you used.

The "no FiveM/GTA code" line does more work than it looks. And the list of natives at the end is your checklist for Step 5. (We haven't scored AI runs with this prompt. It's a starting point, not a tested recipe.)

Step 2: Check the manifest, word for word

fx_version 'cerulean'
game 'rdr3'
rdr3_warning 'I acknowledge that this is a prerelease build of RedM, and I am aware my resources *will* become incompatible once RedM ships.'

name 'swai_board'
description 'Example: a town notice board where the server checks every post'
version '0.1.0'

client_script 'client.lua'
server_script 'server.lua'

This is where RedM differs from FiveM:

Step 3: The client sends and shows, nothing more

-- swai_board/client.lua
-- Runs on the player's PC. It sends the request and shows replies. Nothing else.

RegisterCommand('notice', function(_, args)
    -- /notice valentine Selling a saddle  ->  town = 'valentine', text = 'Selling a saddle'
    local town = args[1]
    local text = table.concat(args, ' ', 2)
    TriggerServerEvent('swai_board:post', town, text)
end, false)

RegisterNetEvent('swai_board:reply', function(message)
    TriggerEvent('chat:addMessage', { args = { 'Notice board', message } })
end)

A player can edit this file or skip it and fire swai_board:post with anything. That's fine, as long as the server never assumes otherwise.

Step 4: The server checks everything

Ask an AI for "a server event that posts a notice" and you may get this:

-- DON'T: trusts the author, the town and the text sent by the client
local board = {}

RegisterNetEvent('swai_board:post', function(authorName, town, text)
    board[town] = board[town] or {}
    table.insert(board[town], authorName .. ': ' .. text)  -- anyone can post as anyone, anywhere, any size
end)

return board

It works the first time you try it. Then someone fires the event by hand with another player's name, a town that doesn't exist, or a message the length of a novel, and the server stores it. On a roleplay server, a fake notice "from" the sheriff is a real problem, not a funny one.

The server should run the same checks every time, in this order:

  1. Who sent it? Use source. The server sets it, and the client can't fake it.
  2. Too soon? A cooldown per player.
  3. Right types? Both values must be strings.
  4. Absurdly big? Reject anything over 500 bytes before doing any work on it.
  5. Allowed town? It must be on a list the server owns.
  6. Sensible text? Strip colour codes and control characters, trim it, reject broken UTF-8, and cap the length.
  7. Only then store it, with the author taken from source.
-- swai_board/server.lua
-- Runs on the server. Everything a client sends is a claim, not a fact.

local TOWNS = { valentine = true, rhodes = true, strawberry = true, blackwater = true }
local MAX_LEN = 80          -- characters (not bytes) per notice
local MAX_BYTES = 500       -- raw input limit, checked before any cleaning
local MAX_PER_TOWN = 5      -- oldest notice falls off the board
local COOLDOWN_MS = 10000

local board = {}       -- [town] = list of { by = server id, name = string, text = string }
local lastPost = {}    -- [player server id] = time of last post

-- Counts UTF-8 characters. Returns nil for invalid UTF-8.
-- Uses utf8.len when the runtime has it (Lua 5.4), else a small fallback.
local function charCount(s)
    if utf8 and utf8.len then
        return utf8.len(s)  -- nil on invalid UTF-8
    end
    local count, i, n = 0, 1, #s
    while i <= n do
        local c = s:byte(i)
        local len
        if c < 0x80 then len = 1
        elseif c >= 0xC2 and c <= 0xDF then len = 2
        elseif c >= 0xE0 and c <= 0xEF then len = 3
        elseif c >= 0xF0 and c <= 0xF4 then len = 4
        else return nil end
        if i + len - 1 > n then return nil end
        for j = i + 1, i + len - 1 do
            local b = s:byte(j)
            if b < 0x80 or b > 0xBF then return nil end
        end
        local c2 = s:byte(i + 1)
        if (c == 0xE0 and c2 < 0xA0) or (c == 0xED and c2 > 0x9F)
            or (c == 0xF0 and c2 < 0x90) or (c == 0xF4 and c2 > 0x8F) then
            return nil  -- overlong, surrogate or out of range
        end
        count = count + 1
        i = i + len
    end
    return count
end

-- Removes chat colour codes: ^0 to ^9 and hex colours like ^#FF0000.
-- Repeats so '^^11' can't rebuild '^1' after the first pass.
local function stripColours(s)
    local a, b
    repeat
        s, a = s:gsub('%^%d', '')
        s, b = s:gsub('%^#%x+', '')
    until a + b == 0
    return s
end

local function reply(target, message)
    TriggerClientEvent('swai_board:reply', target, message)
end

RegisterNetEvent('swai_board:post', function(town, text)
    local src = source  -- the server sets this; save it first

    -- 1. Cooldown per player.
    local now = GetGameTimer()
    if lastPost[src] and now - lastPost[src] < COOLDOWN_MS then
        reply(src, 'Slow down. One notice every 10 seconds.')
        return
    end
    lastPost[src] = now

    -- 2. Types. A modified client can send tables, numbers or nothing.
    if type(town) ~= 'string' or type(text) ~= 'string' then
        reply(src, 'Usage: /notice <town> <message>')
        return
    end

    -- 3. Size limit on the raw input, before any cleaning.
    if #town > MAX_BYTES or #text > MAX_BYTES then
        reply(src, 'That notice is far too long.')
        return
    end

    -- 4. Town must be on the server's own list.
    town = stripColours(town):lower()
    if not TOWNS[town] then
        reply(src, 'Unknown town. Try valentine, rhodes, strawberry or blackwater.')
        return
    end

    -- 5. Clean the text: colour codes, control characters, outer spaces.
    text = stripColours(text):gsub('%c', ''):gsub('^%s+', ''):gsub('%s+$', '')
    local chars = charCount(text)
    if not chars then
        reply(src, 'That notice has invalid characters.')
        return
    end
    if chars == 0 or chars > MAX_LEN then
        reply(src, 'Notices must be 1 to ' .. MAX_LEN .. ' characters.')
        return
    end

    -- 6. Only now change state. The author comes from src, never from the client.
    board[town] = board[town] or {}
    local list = board[town]
    list[#list + 1] = { by = src, name = GetPlayerName(src) or ('player ' .. src), text = text }
    if #list > MAX_PER_TOWN then
        table.remove(list, 1)
    end
    reply(src, 'Pinned to the ' .. town .. ' board.')
end)

-- Server-side command: reads only what the server stored.
RegisterCommand('board', function(src, args)
    if src <= 0 then return end
    local town = type(args[1]) == 'string' and #args[1] <= MAX_BYTES and stripColours(args[1]):lower() or ''
    if not TOWNS[town] then
        reply(src, 'Usage: /board <town>')
        return
    end
    local list = board[town] or {}
    if #list == 0 then
        reply(src, 'The ' .. town .. ' board is empty.')
        return
    end
    for _, notice in ipairs(list) do
        reply(src, notice.name .. ': ' .. notice.text)
    end
end, false)

-- When a player leaves, forget their cooldown and take down their notices.
AddEventHandler('playerDropped', function()
    local src = source
    lastPost[src] = nil
    for _, list in pairs(board) do
        for i = #list, 1, -1 do
            if list[i].by == src then
                table.remove(list, i)
            end
        end
    end
end)

Things worth a second look, since AI tools often get them wrong:

Every function in this script is a shared Cfx one, which is why it can stay this short. As soon as you touch the world (peds, horses, prompts, map blips), you're using RDR3 natives, and that's where AI tools guess the most.

Step 5: Check every native, especially the game ones

Go through the list the AI gave you:

When one is wrong, paste the reference page into the chat and ask the AI to fix that single function. That works much better than "fix the bugs".

Step 6: Lint it

Tell luacheck which Cfx globals exist so it doesn't flag every function. The rdr3_warning line is long, so raise the line limit rather than editing the sentence:

std = 'lua54'
max_line_length = 160 -- the required rdr3_warning line is long
files['swai_board/fxmanifest.lua'] = { read_globals = { 'fx_version','game','rdr3_warning','name','description','version','client_script','server_script' } }
read_globals = { 'RegisterCommand','TriggerServerEvent','RegisterNetEvent','TriggerEvent','TriggerClientEvent','AddEventHandler','GetGameTimer','GetPlayerName','source' }
luacheck swai_board

Our run came back with 0 warnings. Two things to know: if you leave rdr3_warning off the list, luacheck flags it as an undefined variable, and without the higher line limit it flags that line as too long (141 characters against the default 120). Both are config issues. Don't fix them by changing the manifest.

A clean lint means the syntax and names are fine. It says nothing about whether the logic is right.

Step 7: Test it, then try to break it

Load it. Put the folder in resources/[local]/swai_board, run refresh in the server console so it picks up the new resource, then ensure swai_board (server commands). Add ensure swai_board to server.cfg to start it with the server. If the console warns about the RedM pre-release line and the resource won't start, go back to Step 2.

Use it. Join, post a notice, read it back with /board.

Break it.

Test it offline first. You can ask your AI tool for a plain Lua test that fakes the Cfx functions and calls the server handler directly. Ours throws 28 cases at it, including spam, tables instead of strings, a town that isn't listed, 81 characters, 501 bytes, 80 emoji (allowed, since each counts as one), broken UTF-8, colour codes in the town and the message, the five-notice limit, console calls to /board, and a disconnect. It isn't a real server, but it finds logic bugs before you even launch the game.

Common AI mistakes in RedM scripts

What the AI didWhy it's a problemWhat to ask for instead
game 'gta5' or no game lineWrong game, won't run on RedM"Use game 'rdr3'"
Shortened or missing rdr3_warningThe resource won't startPaste the exact line yourself
GTA V natives in RedM codeErrors or does nothing"RDR3 natives only, and list them"
Made-up native namesErrors at runtimeLook each one up
Trusts a name, ID or amount from the clientAnyone can fake it"Use source and decide on the server"
No length cap on player textSpam and huge strings"Reject over 500 bytes, then cap at 80 characters"
Uses # for a character limitEmoji and accents count wrong"Count with utf8.len and reject invalid UTF-8"
Leaves chat colour codes inFake red "server" messages"Strip ^0 to ^9 and ^# colours"
No cooldownOne client can flood the server"Add a per-player cooldown"
Never cleans up on playerDroppedMemory grows, stale data"Clear per-player data on playerDropped"
Mixes VORP and RSG callsBreaks with bothName one framework and its version

Where to go next

Next step

Not affiliated with or endorsed by Rockstar Games, Take-Two, Cfx.re or any framework project. Names are used only to say what this guide is about.