RedM Guidebook Developer API
The almanac is worth writing into from your own resource. A script that ships its own page of documentation can put it in the book the first time it starts, and a script that has something to say can open the right page on a player's screen.
Everything below is server-side unless it says otherwise.
Opening it for a player
exports.rm_guidebook:open(source) -- the almanac, on its first leaf
exports.rm_guidebook:open(source, 'rules') -- on one entry
exports.rm_guidebook:openPress(source) -- the press, if they may run itBoth return false and draw nothing if the player may not read, may not run the press, or named an entry their job cannot see.
The player's own permissions always apply
An export cannot open a leaf its reader is not allowed. There is no privileged path.
For a script that cannot call a Lua export:
TriggerEvent('rm_guidebook:api:open', source, 'rules')
TriggerEvent('rm_guidebook:api:press', source)Client-side, if you would rather not round-trip through your own server file:
exports.rm_guidebook:open() -- asks the server to open the almanac
exports.rm_guidebook:openEntry('rules')
exports.rm_guidebook:isOpen() -- boolean
exports.rm_guidebook:route(x, y, z) -- mark a place on this player's mapensure(kind, record) — write it if the slug is free
This is the one a script that ships documentation wants. It writes the record if nothing holds that slug and does nothing at all if something does — so calling it on every start is correct, the second start is a no-op, and a server owner who edited your page keeps their edits.
CreateThread(function()
Wait(5000) -- let the almanac finish loading
exports.rm_guidebook:ensure('chapter', {
slug = 'blacksmithing',
title = 'Blacksmithing',
rank = 40,
shown = true,
unfurled = false,
})
local ok, why = exports.rm_guidebook:ensure('entry', {
slug = 'blacksmith-basics',
chapter = 'blacksmithing',
title = 'Working the forge',
rank = 1,
body = [[<h2>Working the forge</h2>
<p>Heat, hammer, quench. In that order, and not too long on the first.</p>]],
})
if not ok then print('almanac refused the entry: ' .. tostring(why)) end
end)Returns ok, reason. reason is a refusal key — the same vocabulary the overlay uses:
| Reason | |
|---|---|
taken | The slug is already held |
orphan | The named chapter does not exist |
invalid | The record failed the schema |
unready | The ledger has not finished loading — wait and try again |
unready happens because the almanac needs the database, which needs oxmysql, which may be slower than your resource. That is what the Wait(5000) is for.
commit(kind, record) — write it, replacing what is there
exports.rm_guidebook:commit('entry', {
slug = 'server-status',
chapter = 'welcome',
title = 'Server status',
body = ('<p>Last restart: %s</p>'):format(os.date()),
})No permission check — your script is trusted — but the record still goes through the schema, and a malformed one is refused rather than stored. Every connected player sees the change immediately.
Which to use
ensure for anything a human might want to edit afterwards. commit only for a page your script owns outright.
strike(kind, slug)
exports.rm_guidebook:strike('entry', 'server-status')Striking a chapter strikes its entries with it.
Reading it
local entry = exports.rm_guidebook:find('entry', 'rules') -- one record, or nil
local chapters = exports.rm_guidebook:list('chapter') -- every record, by rank
local isStaff = exports.rm_guidebook:isStaff(source) -- booleanfind on an entry gives you the body. These read the in-memory shelf, not the database, so they are cheap and safe to call in a loop.
The three kinds
'chapter', 'entry', 'signpost'. Every field is optional except the ones marked, and anything you leave out takes a sensible default.
chapter
slug | required — lowercase, digits, dashes, underscores; 3–48 |
title | required — up to 120 characters |
rank | ascending, smallest first. Default 1 |
shown | in the index. Default true |
unfurled | open by default in the index. Default true |
access | { on = bool, jobs = { { job = 'police', grade = 1 } } } |
entry — as above, minus unfurled, plus:
chapter | required — the chapter's slug, which must exist |
body | HTML, up to 512 KiB |
revision | only meaningful to the press; scripted writes ignore it |
signpost
slug, title | required |
pos | required — { x, y, z } |
entry or body | required — one or the other, never both |
routable | may be marked on a map. Default true |
text | { size, font, color = {r,g,b}, reach } |
blip | { enabled, sprite, tint, scale } |
marker | { enabled, kind, size = {x,y,z}, tint, spin, angle, reach } |
shown, access | as above |
Sprite, tint, marker kind and font are validated against the catalogues in config/atlas.lua. An unknown value falls back to the first in its list rather than being refused — a signpost with an odd sprite still stands.
Bodies
Bodies are HTML, sanitised in the overlay before they are drawn. Headings and lists all work; anything dangerous is stripped when read, not when written.
A directions button is a plain anchor the reader turns into a call:
<a class="gb-route" data-x="-306.4" data-y="806.2" data-z="118.9">Sheriff's office</a>Pictures take a width and an alignment:
<img src="https://example.com/forge.png" width="520" data-align="center" />Both are exactly what the press writes, so a page authored by a script and one authored by hand are the same thing.
Adding a framework
Four files, two of them small. An adapter is a table with a fixed shape that registers itself into RmGuidebook.Adapters; nothing else in the resource knows a framework exists.
client/adapters/yours.lua
RmGuidebook.Adapters = RmGuidebook.Adapters or {}
RmGuidebook.Adapters.yours = {
id = 'yours',
label = 'Your Framework',
resource = 'your_core', -- probed by name when framework = 'auto'
bind = function() end, -- -> boolean; resolve handles HERE, not at file scope
notify = function(self, text, tone, ms) end, -- -> boolean drawn
onReady = function(self, fn) end, -- call fn() when the character is ready
job = function(self) end, -- -> name|nil, grade|nil
onJob = function(self, fn) end, -- call fn() when the job may have changed
}server/adapters/yours.lua
RmGuidebook.Adapters.yours = {
id = 'yours',
label = 'Your Framework',
resource = 'your_core',
bind = function() end, -- -> boolean
identity = function(self, src) end, -- -> { identifier, charId, name, groups, job, grade }
staff = function(self, src) end, -- -> boolean, or nil if the framework has no such call
jobs = function(self) end, -- -> { { name, label, grades = { { level, label } } } }
onReady = function(self, fn) end, -- call fn(src) when a character loads
}Then add 'yours' to the ORDER list in client/link.lua and server/bridge.lua, and it is probed like the rest.
Three rules the existing four all follow:
Resolve handles in bind, never at file scope
Resource start order is not guaranteed, and asking a core for its export too early throws.
- Return
falsefrombindrather than throwing. A framework that is installed but broken should degrade to standalone, not take the resource down. staffmay benil. Only frameworks with a real permission call need it; the others are covered by the group list.
This needs the Open Source edition
The adapter files are encrypted on the escrow build. Escrow customers can ask on Discord and an adapter can ship in a future update.
The wire
Every event name is a constant in shared/protocol.lua and nothing outside that file spells one out — tools/audit.py fails the build if anything does. If you are extending the resource rather than talking to it, add your name there first.
The transport is Signal.up.call / Signal.down.reply with a token, not a framework callback system, because none of the four spell theirs the same way and a standalone server has none. Bridge.rpc(name, fn) on the server and Link.ask(name, ...) on the client are the whole of it.
Working on the overlay
Open Source edition only
web/src/ and the build are not in an escrow zip — it ships web/dist already built. Everything above this heading applies to both editions.
cd web
npm install
npm run dev # port 5322, with a demo almanac and a bar to drive it
npm run build # writes web/dist, which is what the game servesnpm run dev opens in a browser with no game attached: lib/mock.js answers every call with plausible data, so the interface can be built and looked at without starting a server. Neither it nor the dev bar is in the shipped bundle.
Two checks that need no game server:
node tools/simulate.cjs # runs the shared Lua under a real interpreter
python tools/audit.py # static checks over Lua, JS and the localesRun both before shipping. The second is what catches the wire vocabulary drifting between shared/protocol.lua and web/src/lib/nui.js, which is otherwise a message that silently never arrives.