RDUI OS DOCS
RED DRAGON ELITE // OPEN SOURCE

The phone operating system
for FiveM servers.

RDUI OS is rde_phone's app platform: one SDK, a real design system, and a registry any resource can plug an app into — without touching a line of the phone's own code. No React. No Module Federation. No CDN dependencies.

4
Built-in Apps
3
Production Ports
0
CDN Dependencies
2
Languages
01 / Start Here

Overview

rde_phone is a FiveM smartphone resource built for one purpose: making it fast to give any other resource its own polished phone app, without that resource needing to know anything about NUI internals, iframes, or cross-resource plumbing.

Most FiveM phone frameworks solve this with Module Federation — a React app split across resources, sharing a runtime, loaded via a remoteEntry.js. It works, until it doesn't: shared React versions drift, federation chunks fail to resolve, and debugging a broken remote entry eats a night you didn't plan for. rde_phone doesn't use it at all.

Core Principle Every app — including the four built into the phone itself — is a sandboxed <iframe> loading plain HTML/CSS/JS. Built-in apps get zero special treatment. If you can write a script tag, you can build a phone app.

Why this exists

The alternative most servers ship with couples every app's frontend to a shared bundler, a shared React tree, and a shared point of failure. One app's dependency bump can break every other app on the phone. rde_phone's iframe-per-app model means a broken app is a broken app — not a broken phone.

Design pillars

PillarWhat it means in practice
Zero dependencyIcons, fonts, and the app SDK all ship inside the resource. No CDN calls at runtime — CEF's network layer is not something you want in your critical path.
One bridgeEvery app talks to Lua through a single sdk:request relay. No per-app NUI callback wiring.
Real registryApps register themselves at runtime via an export. The homescreen has no hardcoded app list.
Config-driven i18nEnglish by default, other languages are additive — never hand-translated per app.
02 / Start Here

Quick Start

Installing rde_phone and getting the base OS running.

1. Dependencies & load order

rde_phone depends on ox_core, ox_lib, oxmysql, and ox_inventory. Any resource that registers an app should depend on rde_phone so it always starts first.

server.cfg
-- load order matters
ensure ox_core
ensure ox_lib
ensure oxmysql
ensure ox_inventory
ensure rde_phone
-- any app resource goes after rde_phone
ensure rde_banking

2. Register the phone item

The phone opens only if the player is holding the item — no item, no phone. Register it in ox_inventory's own item list:

ox_inventory/data/items.lua
['phone'] = {
    label = 'Phone',
    weight = 190,
    stack = false,
    close = true,
    client = {
        export = 'rde_phone.use_phone',
    },
},

3. Open it

F2 by default (Config.OpenKey), or use the item from the inventory. Both routes go through the same server-validated check — pressing the key with no phone in your inventory does nothing but show an error toast.

Server-authoritative by design Item possession is checked server-side before the NUI even opens. A client-side check here would be trivially bypassable — never trust the client for anything that gates a feature.
03 / Start Here

Architecture

Every request from every app — built-in or third-party — takes the same four-hop path.

App
iframe, any resource
SDK
postMessage
Shell
phone.js, fetch()
Lua Client
NUI callback
Lua Server
lib.callback

The iframe boundary

Apps never call Lua directly — NUI only talks to the resource that served the current page. So the app calls the SDK, the SDK posts a message to the parent window (the phone shell), and the shell is the only thing that ever actually calls fetch() against a NUI endpoint.

Cross-resource apps

A third-party app doesn't have to live inside rde_phone's own files. Any resource can serve its own app and reference rde_phone's shared assets by their cross-resource NUI origin:

your_app/web/phone/index.html
<link rel="stylesheet" href="https://cfx-nui-rde_phone/web/theme/rdui-phone.css" />
<script src="https://cfx-nui-rde_phone/web/vendor/lucide.js"></script>
<script src="https://cfx-nui-rde_phone/web/sdk/rde-phone-sdk.js"></script>

Your app's own backend calls don't go through RDEPhone.request() though — that only reaches rde_phone's own server callbacks. For your own data, fetch() your own resource's NUI endpoint directly. See Your First App for the full pattern.

Common confusion RDEPhone.request(action) = talk to rde_phone. Plain fetch('https://your_resource/endpoint') = talk to your own resource. Apps typically use both.
04 / Build Apps

SDK Reference

One script tag gives every app the full SDK surface:

index.html
<script src="../../sdk/rde-phone-sdk.js"></script>
MethodSignatureNotes
requestRDEPhone.request(action, payload) → PromiseCalls a rde_phone:server:* callback. Rejects on {error:true} or an 8s timeout.
onRDEPhone.on(event, callback)Subscribes to shell pushes: sms:received, phone:stateSync, custom events you broadcast.
notifyRDEPhone.notify({title, body, icon})Native-looking toast, only while your app's iframe is loaded. For background notifications, see Push Notifications.
closeRDEPhone.close()Closes the whole phone, e.g. after a successful action.
tRDEPhone.t(key, ...args) → stringTranslates a key from rde_phone's own Config.Locales. %s placeholders fill in order.
readyRDEPhone.ready → PromiseResolves once locale + player language are loaded. Await this before your first render if you use t().
getLanguageRDEPhone.getLanguage() → stringCurrent cached language code, e.g. 'en'.

Example: fetching and rendering data

app.js
await RDEPhone.ready;
const contacts = await RDEPhone.request('getContacts');
list.innerHTML = contacts.map(c => `<div>${c.name}</div>`).join('');

RDEPhone.on('sms:received', (msg) => {
    RDEPhone.notify({ title: 'New message', body: msg.body, icon: 'message-circle' });
});
05 / Build Apps

Your First App

A complete, working app in under 40 lines. No build step, no bundler — this file runs as-is.

1. The Lua side

Register a server callback for your app's data, and register the app itself so it shows up on the homescreen.

server.lua
lib.callback.register('my_app:cb:phoneGetGreeting', function(src)
    local player = Ox.GetPlayer(src)
    return { message = 'Hello, ' .. (player and player.name or 'stranger') }
end)

local function RegisterMyApp()
    pcall(function()
        exports.rde_phone:RegisterPhoneApp({
            slug  = 'my_app',
            title = 'My App',
            icon  = 'sparkles',      -- any Lucide icon name
            color = '#a855f7',       -- optional icon tint
            url   = ('https://cfx-nui-%s/web/phone/index.html'):format(GetCurrentResourceName()),
            order = 70,
        })
    end)
end

AddEventHandler('onResourceStart', function(name)
    if name ~= GetCurrentResourceName() then return end
    SetTimeout(500, RegisterMyApp)
end)
AddEventHandler('rde_phone:ready', RegisterMyApp) -- covers the other start order too

2. The client-side bridge

NUI can only reach the resource that served the page — you need one relay callback so your app can reach your own server callbacks.

client.lua
RegisterNUICallback('phone:request', function(data, cb)
    CreateThread(function()
        local ok, result = pcall(function()
            return lib.callback.await('my_app:cb:phone' .. data.action, false, data.payload)
        end)
        cb(ok and result or { error = true })
    end)
end)

3. The app itself

web/phone/index.html
<link rel="stylesheet" href="https://cfx-nui-rde_phone/web/theme/rdui-phone.css">
<script src="https://cfx-nui-rde_phone/web/sdk/rde-phone-sdk.js"></script>

<div id="greeting"></div>

<script>
const resourceName = GetParentResourceName();
async function myRequest(action, payload) {
  const r = await fetch(`https://${resourceName}/phone:request`, {
    method: 'POST', body: JSON.stringify({ action, payload }),
  });
  return r.json();
}

(async () => {
  const { message } = await myRequest('GetGreeting');
  document.getElementById('greeting').textContent = message;
})();
</script>
That's the whole app No package.json, no build step, no shared React tree with the other nine apps on the phone. Save, restart the resource, done.
06 / Build Apps

App Registry

The homescreen has no hardcoded app list — every icon on it, including the four built-in apps, came from a call to RegisterPhoneApp.

FieldRequiredNotes
slugYesUnique identifier, e.g. 'banking'.
titleOne of title / titleKeyLiteral string, shown as-is.
titleKeyOne of title / titleKeyTranslation key — resolved per-player against Config.Locales, only meaningful for apps sharing rde_phone's own locale table.
iconYesAny Lucide icon name.
colorNoHex string. Developer-set icon tint — not a user setting.
urlYesFull URL, typically https://cfx-nui-<your_resource>/....
orderNoSort position, lower first. Built-ins use 10–90.

Ordering-race safety

If your resource starts before rde_phone, the export call would fail. Handle both directions:

server.lua
AddEventHandler('onResourceStart', function(name)
    if name ~= GetCurrentResourceName() then return end
    SetTimeout(500, RegisterMyApp)   -- covers: rde_phone already running
end)
AddEventHandler('rde_phone:ready', RegisterMyApp) -- covers: rde_phone starts after you

Simplest fix: just declare rde_phone as a dependency in your fxmanifest.lua and FXServer guarantees the order — the event fallback is defense in depth for when someone restarts resources individually.

Removing an app

server.lua
exports.rde_phone:UnregisterPhoneApp('my_app')
07 / Build Apps

Localization

English is the default everywhere. Every string — Lua-side messages and NUI-side UI text — lives in exactly one place: Config.Locales in config.lua. Neither Lua nor the NUI keep a second copy.

config.lua
Config.DefaultLanguage = 'en'
Config.Locales = {
    en = { greeting = 'Hello, %s' },
    de = { greeting = 'Hallo, %s' },
}

Lua side reads via Config.GetString(key, ...). NUI apps read the exact same table through the SDK:

app.js
await RDEPhone.ready;
title.textContent = RDEPhone.t('greeting', playerName);
Live language switching RDEPhone.t() caches the language once at load. If your app changes the language while already open (like the Settings app does), keep your own small local translator tied to the live value instead of waiting for the SDK's cache to catch up.
08 / Build Apps

Push Notifications

RDEPhone.notify() only works while your app's iframe is actually loaded. For events that happen while the player is on a different app — or has the phone closed entirely — use the generic server-side push API:

server.lua
exports.rde_phone:PushNotification(targetSource, {
    title = 'Bank',
    body  = 'Wire received: $500',
    icon  = 'landmark',
    app   = 'banking',  -- ties the badge count to your homescreen icon
})

Gated server-side by phone possession — same check as every other phone action. If the target doesn't have a phone on them, it silently no-ops, exactly like a real phone not vibrating in a drawer.

09 / Design

Design System

Reusable classes from theme/rdui-phone.css. Link it once, get every component below for free.

Glass buttons

Semi-transparent, blurred, with a diagonal shine — reserved for primary actions. Tint is driven by CSS custom properties, so any color works without redefining the blur/shine/border treatment.

📞
🏦
usage
<div class="glyph rdui-glass" style="--glass-c1:20,255,20;--glass-c2:0,60,0;--glass-glow:0,255,0;"></div>

Buttons, toggles, badges

Solid
Ghost
Gold Tier
ClassUse for
.rdui-btn.solidPrimary action button
.rdui-btn.ghostCancel / secondary action
.rdui-toggle / .onSettings-style switch
.rdui-modal-backdrop / .rdui-modalBottom-sheet dialog
.rdui-list-itemRow in a scrollable list
.rdui-avatarCircular icon/photo slot
.rdui-fabFloating action button, bottom-right
.rdui-emptyEmpty-state message
.rdui-badge.countNotification count dot

Icons

Lucide, self-hosted — web/vendor/lucide.js. After changing the DOM, call lucide.createIcons() again; it only converts <i data-lucide> tags present at call time.

Easy to miss Call refreshIcons() unconditionally once at script load for static markup — not only after a successful data fetch. Every early return (empty state, error state) otherwise skips it and those icons stay invisible.
10 / Design

Common Pitfalls

Real bugs found and fixed building the built-in apps and three production ports. Worth reading before you hit them yourself.

window.prompt() / alert() / confirm() freeze the NUI

FiveM's NUI renders through CEF off-screen rendering, which cannot display a native browser dialog. The call blocks forever waiting on a modal response that never comes — the whole game stops responding to input, and only restart your_resource recovers it.

Fix: build a small in-page HTML modal instead. See the bottom-sheet pattern in Design System.

MySQL.query() without .await is fire-and-forget

If a resource restarts moments after a player saves something, the write may never land — the connection tears down before the async query completes. Looks like "settings randomly reset," is actually a race condition.

Fix: use .await on every write inside a callback. Callbacks already run in their own coroutine — there's no reason not to.

local Config = {} in a shared_script only exists in that file

shared_scripts don't capture return values like require() does — each file is its own Lua chunk. A local table in config.lua is invisible from server.lua or client.lua, which then see a nil global and error on first use.

Fix: declare shared config as a bare global — Config = {}, no local, no return.

lib.callback.await() only works client → server

It's tempting to call it from inside another server callback to reuse logic. It silently times out — there's no server-to-server variant.

Fix: extract the shared logic into a plain Lua function both callbacks call directly.

ID-selector CSS beats a class-based tint every time

#homescreen { background: ... } will always override a .wp-crimson class on the same element — ID specificity wins regardless of source order in the stylesheet.

Fix: don't declare the property at the ID level at all if a class is meant to own it.

11 / Reference

Built-in Apps

SMS

Messages

Threads, chat view, live delivery — gated by phone possession on the receiving end, same as every push.

Call

Calls

Dialer + call log, real voice via pma-voice channel switching. Ring timeout auto-resolves to "missed."

CON

Contacts

List, add, tap-to-call, tap-to-message shortcuts into the other apps.

SET

Settings

Wallpaper (presets + custom image URL), ringtone/SMS tone selection with live preview, language switch.

12 / Reference

Case Studies

Three production resources ported from NPWD/Module Federation onto this SDK. Same architecture, three very different feature sets.

$

rde_banking

Full banking: deposit, withdraw, transfer, investments, loans, prestige tiers.

  • Offline-safe payouts via a dedicated balance-adjustment export
  • Core money logic shared between the world-UI and the phone app — one source of truth, not duplicated
🚗

rde_carservice

Garage vehicle delivery & pickup, called from the phone instead of walking to a fixed NPC.

  • The delivery/pickup animation logic never changed — the phone just triggers the same NUI callbacks the world-menu already used
🛡

rde_bodyguards

Squad orders, roster, and a public marketplace — list a bodyguard, anyone can browse and buy.

  • Marketplace purchases run through rde_banking's offline-safe export so a seller gets paid even while logged out
  • Atomic UPDATE ... WHERE status='active' claim prevents double-selling under concurrent buys
13 / Reference

FAQ

Do apps need a build step?

No. Every app is plain HTML/CSS/JS served as-is. No bundler, no package.json, no compile step between editing a file and seeing the change.

Can I use React or Vue in my app?

Nothing stops you from including a framework via its own script tag inside your app's iframe — it's fully isolated from every other app. You'd be opting back into the exact dependency surface rde_phone was built to avoid, but the architecture doesn't forbid it.

What if my app needs data rde_phone doesn't expose?

That's expected — your app talks to your own resource's server callbacks via the client-side phone:request relay pattern in Your First App, not through rde_phone at all.

Does the registry survive a resource restart?

The registry itself lives in rde_phone's memory. If your resource restarts, it re-registers on its own onResourceStart. If rde_phone restarts, every dependent resource re-registers via the rde_phone:ready event.

Can I remove the built-in apps?

Call exports.rde_phone:UnregisterPhoneApp(slug) with 'sms', 'calls', 'contacts', or 'settings' — they go through the exact same registry as everything else, no special protection.

14 / Reference

Changelog

Latest

Public bodyguard marketplace, offline-safe cross-resource bank transfers.

rde_carservice and rde_bodyguards ported off NPWD onto the SDK.

rde_banking ported off NPWD — first production proof of the app registry.

Dynamic app registry shipped — homescreen is no longer hardcoded.

RDUI OS visual identity — RDWE-UI design tokens, self-hosted Lucide icons, glass buttons.

Core phone shipped — SMS, Calls (real voice via pma-voice), Contacts, Settings.