Skip to content

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 ​

lua
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 it

Both 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:

lua
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:

lua
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 map

ensure(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.

lua
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
takenThe slug is already held
orphanThe named chapter does not exist
invalidThe record failed the schema
unreadyThe 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 ​

lua
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) ​

lua
exports.rm_guidebook:strike('entry', 'server-status')

Striking a chapter strikes its entries with it.

Reading it ​

lua
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)         -- boolean

find 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

slugrequired — lowercase, digits, dashes, underscores; 3–48
titlerequired — up to 120 characters
rankascending, smallest first. Default 1
shownin the index. Default true
unfurledopen by default in the index. Default true
access{ on = bool, jobs = { { job = 'police', grade = 1 } } }

entry — as above, minus unfurled, plus:

chapterrequired — the chapter's slug, which must exist
bodyHTML, up to 512 KiB
revisiononly meaningful to the press; scripted writes ignore it

signpost

slug, titlerequired
posrequired — { x, y, z }
entry or bodyrequired — one or the other, never both
routablemay 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, accessas 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:

html
<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:

html
<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

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

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 false from bind rather than throwing. A framework that is installed but broken should degrade to standalone, not take the resource down.
  • staff may be nil. 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.

bash
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 serves

npm 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:

bash
node tools/simulate.cjs   # runs the shared Lua under a real interpreter
python tools/audit.py     # static checks over Lua, JS and the locales

Run 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.

Documentation for RedMorrow. Scripts are licensed per server — redistribution is not permitted.