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.
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.
<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
| Pillar | What it means in practice |
|---|---|
| Zero dependency | Icons, 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 bridge | Every app talks to Lua through a single sdk:request relay. No per-app NUI callback wiring. |
| Real registry | Apps register themselves at runtime via an export. The homescreen has no hardcoded app list. |
| Config-driven i18n | English by default, other languages are additive — never hand-translated per app. |
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.
-- 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:
['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.
Architecture
Every request from every app — built-in or third-party — takes the same four-hop path.
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:
<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.
RDEPhone.request(action) = talk to rde_phone. Plain fetch('https://your_resource/endpoint') = talk to your own resource. Apps typically use both.
SDK Reference
One script tag gives every app the full SDK surface:
<script src="../../sdk/rde-phone-sdk.js"></script>
| Method | Signature | Notes |
|---|---|---|
| request | RDEPhone.request(action, payload) → Promise | Calls a rde_phone:server:* callback. Rejects on {error:true} or an 8s timeout. |
| on | RDEPhone.on(event, callback) | Subscribes to shell pushes: sms:received, phone:stateSync, custom events you broadcast. |
| notify | RDEPhone.notify({title, body, icon}) | Native-looking toast, only while your app's iframe is loaded. For background notifications, see Push Notifications. |
| close | RDEPhone.close() | Closes the whole phone, e.g. after a successful action. |
| t | RDEPhone.t(key, ...args) → string | Translates a key from rde_phone's own Config.Locales. %s placeholders fill in order. |
| ready | RDEPhone.ready → Promise | Resolves once locale + player language are loaded. Await this before your first render if you use t(). |
| getLanguage | RDEPhone.getLanguage() → string | Current cached language code, e.g. 'en'. |
Example: fetching and rendering data
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' });
});
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.
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.
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
<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>
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.
| Field | Required | Notes |
|---|---|---|
| slug | Yes | Unique identifier, e.g. 'banking'. |
| title | One of title / titleKey | Literal string, shown as-is. |
| titleKey | One of title / titleKey | Translation key — resolved per-player against Config.Locales, only meaningful for apps sharing rde_phone's own locale table. |
| icon | Yes | Any Lucide icon name. |
| color | No | Hex string. Developer-set icon tint — not a user setting. |
| url | Yes | Full URL, typically https://cfx-nui-<your_resource>/.... |
| order | No | Sort 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:
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
exports.rde_phone:UnregisterPhoneApp('my_app')
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.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:
await RDEPhone.ready;
title.textContent = RDEPhone.t('greeting', playerName);
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.
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:
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.
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.
<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
| Class | Use for |
|---|---|
| .rdui-btn.solid | Primary action button |
| .rdui-btn.ghost | Cancel / secondary action |
| .rdui-toggle / .on | Settings-style switch |
| .rdui-modal-backdrop / .rdui-modal | Bottom-sheet dialog |
| .rdui-list-item | Row in a scrollable list |
| .rdui-avatar | Circular icon/photo slot |
| .rdui-fab | Floating action button, bottom-right |
| .rdui-empty | Empty-state message |
| .rdui-badge.count | Notification 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.
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.
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.
Built-in Apps
Messages
Threads, chat view, live delivery — gated by phone possession on the receiving end, same as every push.
Calls
Dialer + call log, real voice via pma-voice channel switching. Ring timeout auto-resolves to "missed."
Contacts
List, add, tap-to-call, tap-to-message shortcuts into the other apps.
Settings
Wallpaper (presets + custom image URL), ringtone/SMS tone selection with live preview, language switch.
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
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.
Changelog
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.