How to make a RedM script with AI
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:
- A local RedM test server. The docs cover txAdmin and a manual setup. For RedM,
server.cfgneedsset gamename rdr3. Never test on a live server. - A legal copy of Red Dead Redemption 2 to join with (the docs list where to get it).
- Your usual AI coding tool, a code editor, and luacheck if you want the lint step (optional, but worth it).
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:
game 'rdr3'means RedM.gta5is FiveM, andcommonruns on either but only gets the shared Cfx functions (resource manifest reference). The same page notes that RedM needs agameentry.rdr3_warninghas to be there, and the text has to match exactly, asterisks included. If it's missing, the server warns and the resource won't start. AI tools often shorten or "tidy" this sentence. Don't let them. Copy it from a known-good manifest, such as the one in Cfx.re's own chat resource.fx_version 'cerulean'is the current version, per the same manifest reference.lua54 'yes'is no longer needed. The docs mark it deprecated: since June 2025 every Lua script runs on 5.4. Plenty of RedM templates still include it. If your AI adds it, that's a hint its examples are older, so read the rest with that in mind.
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)
RegisterCommandgives the handler the words after the command asargs. The first word is the town, andtable.concatglues the rest back into the message.TriggerServerEventsends both values to the server, which may do whatever it likes with them.RegisterNetEventlets the server's reply reach this client (listening for events).chat:addMessagecomes from the default chat resource, not from the game. That resource is built for both RedM and FiveM, but it has to be running.
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:
- Who sent it? Use
source. The server sets it, and the client can't fake it. - Too soon? A cooldown per player.
- Right types? Both values must be strings.
- Absurdly big? Reject anything over 500 bytes before doing any work on it.
- Allowed town? It must be on a list the server owns.
- Sensible text? Strip colour codes and control characters, trim it, reject broken UTF-8, and cap the length.
- 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:
local src = sourceon the first line. The docs say the globalsourceis only valid for the first call of the event, and it's reset after aWait. Saving it once avoids a bug that only shows up later.- The author's name comes from
GetPlayerName(src)on the server, never from the client. GetGameTimerworks on the server and returns milliseconds (native reference), which is all a cooldown needs./boardis a server command. Itsargscome from the server's own command parser, but the code still checks the town against the list, which costs one line.playerDroppedfires withsourceset to the player who left (event reference). Here it clears the cooldown and takes their notices down. Without it, the tables grow for as long as the server runs, and the board shows notices from people who aren't there.- Characters, not bytes. Lua's
#counts bytes, so an emoji would count as four.charCountusesutf8.lenfrom Lua 5.4 and falls back to a small counter on older Lua. Both return nothing for broken UTF-8, and the post is rejected. (This counts code points, so an emoji built from several joined code points counts as more than one.) - Colour codes. The chat resource turns
^1and friends into colours, so a player could paint a notice red to make it look like a server message.stripColoursremoves^0to^9and the chat's hex colours (^#FF0000), and repeats so^^11can't rebuild a code after the first pass. - The 500-byte check comes first, so a huge string is thrown away before the server spends time cleaning it.
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:
- Shared Cfx functions like
RegisterCommand,TriggerClientEventandGetPlayerNameare documented on docs.fivem.net and work the same on RedM. - Game natives are different. A GTA V native name in a RedM script is a common AI mistake, and so is a plausible-sounding name that doesn't exist at all. Look up RDR3 natives on rdr3natives.com, a community reference. Many RDR3 natives have no friendly name yet and are called by hash, so a confident friendly name from an AI deserves a second look.
- Which side does it run on? Client natives in
server.luaerror, and the reverse.
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.
- Post twice in a row. The second one should get "Slow down".
- Try
/notice saintdenis hello,/notice valentinewith no message, a message far over 80 characters, and/notice valentine ^1SHERIFF: town closed. - Use a second player and check that each has their own cooldown and that names come out right.
- Post, leave, and have the other player read the board. Your notice should be gone.
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 did | Why it's a problem | What to ask for instead |
|---|---|---|
game 'gta5' or no game line | Wrong game, won't run on RedM | "Use game 'rdr3'" |
Shortened or missing rdr3_warning | The resource won't start | Paste the exact line yourself |
| GTA V natives in RedM code | Errors or does nothing | "RDR3 natives only, and list them" |
| Made-up native names | Errors at runtime | Look each one up |
| Trusts a name, ID or amount from the client | Anyone can fake it | "Use source and decide on the server" |
| No length cap on player text | Spam and huge strings | "Reject over 500 bytes, then cap at 80 characters" |
Uses # for a character limit | Emoji and accents count wrong | "Count with utf8.len and reject invalid UTF-8" |
| Leaves chat colour codes in | Fake red "server" messages | "Strip ^0 to ^9 and ^# colours" |
| No cooldown | One client can flood the server | "Add a per-player cooldown" |
Never cleans up on playerDropped | Memory grows, stale data | "Clear per-player data on playerDropped" |
| Mixes VORP and RSG calls | Breaks with both | Name one framework and its version |
Where to go next
- Read the resource manifest reference and the events docs once, all the way through.
- For money, jobs and inventory, you'll add a framework such as VORP or RSG. Pick one and put its name and version in every prompt.
Next step
- The RedM Server Pack (coming soon) is a set of prompts built around the same rule: the server decides.
- New to RedM servers? Our beginner guide isn't on the site yet. Until it is, start from the RedM and FiveM prompts hub.
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.