Load screen — building a splash
The load screen is the custom FiveM connecting splash: what a player looks at while the server streams in, in place of the stock GTA loading screen. In RivalityFX it isn't one HTML file — it's a small platform, so the brand surface is something you can rebrand, fork, and (eventually) ship as its own resource.
The shipped resource is loadscreen (under resources/[platform]/). Enable it with ensure loadscreen (see Server configuration).
Two halves. The host is the thin shell FiveM mounts; the splash is the page you design. The host owns the messy part — FiveM's raw load lifecycle — and streams a clean, smoothed signal to the splash. You only render. That split is the whole point: a splash never touches a FiveM event, so it's safe to fork and safe to swap.
Architecture
FiveM load events ─▶ HOST (host.html / host.js) ──postMessage──▶ SPLASH (your iframe)
• smooths progress (monotonic) • renders progress
• maps phases → status text • renders status text
• forwards brand theme + Discord • matches the brand
• shows a HARD FALLBACK until you're ready • RfxSplash.ready()- Host (
html/host.html+host.js+host.css) — the page FiveM shows. It mounts the active splash in a sandboxed<iframe>, translates FiveM's raw load events into a clean progress stream, and forwards brand/config over the protocol. It owns no visual identity of its own beyond a hard fallback. - Splash (a folder like
splash-default/) — a self-contained NUI page. It renders the host's stream however it likes and owns its own assets (fonts, CSS, art). This is what you build. rfx-splash.js— the splash-side SDK (RfxSplash). Include it and you never touchpostMessagedirectly.client.lua— a tiny runtime bridge. The host is pure NUI shown before the player's session exists, so it can't read convars; this pushes the live Discord invite in during the load phase (details below).
The bundled splash-default/ is both the shipped brand splash and the reference
implementation of everything below — your starting point is a copy of it.
The protocol
Every message carries an rfx discriminator. You rarely read this table directly — RfxSplash
wraps it — but it's the contract (the canonical copy lives in html/SPLASH-API.md).
Host → splash (downstream)
rfx | Payload | When |
|---|---|---|
init | { theme, discord, server } | once, right after the splash announces ready (and again if config arrives later) |
progress | { fraction: 0..1, pct: 0..100 } | as load advances — already smoothed + monotonic |
status | { key, text } | on each load phase (init / data / map / start) |
done | {} | reserved — load finished (optional outro). Not emitted yet. |
Splash → host (upstream)
rfx | Payload | Meaning |
|---|---|---|
ready | {} | mounted — start the stream, hide the hard fallback (re-announced until init arrives, to beat the mount race) |
fail | { error } | give up — the host shows its hard fallback (the floor) |
The status keys the host emits, in order, are init → data → map → start (the splash
starts on a synthetic connecting). Use the text for display; switch on the key if you want
your own copy or per-phase art.
The splash SDK (RfxSplash)
The only API a splash author touches. Subscribe to the stream, announce you're mounted, render.
RfxSplash
.on("init", function (d) { // d = { theme, discord, server }
if (d.discord) link.href = d.discord;
})
.on("progress", function (p) { // p = { fraction: 0..1, pct: 0..100 }
fill.style.width = p.pct + "%";
})
.on("status", function (s) { // s = { key, text }
label.textContent = s.text;
})
.on("done", function () { /* optional outro — reserved */ });
RfxSplash.ready(); // announce you're mounted → host hides the fallback and starts streaming| Member | What it does |
|---|---|
.on(ev, cb) | Subscribe to "init" / "progress" / "status" / "done". Chainable. |
.ready() | Tell the host you're mounted. Re-announced every 100 ms until the host acks — so it doesn't matter whether the iframe or the host listener came up first. Call this once you can render. |
.fail(err) | Bail out (e.g. a required asset failed to load). The host drops back to the hard fallback so the player is never stuck on a broken page. |
.theme | Brand tokens, once init arrives (see below). |
.discord | The live Discord invite, once init arrives. |
.server | Reserved — server name/mode (Phase 2+). |
Why
ready()matters. The host shows its hard fallback the instant FiveM mounts it, and keeps it up until your splash callsready(). Only then does it fade your splash in and start the stream. If you neverready()(or youfail()), the player keeps the fallback — a working, moving progress bar — instead of a blank or half-built page. Callready()as soon as your DOM can accept the stream; you'll receive a flush of the lateststatus+progressright after.
Build your own splash
A splash is plain front-end work — HTML/CSS/JS, no build step. Today the simplest path is to rebrand the bundled default in place; a fully separate splash is a copy + a one-line host change (the automatic pool that picks splashes for you is Phase 2).
1 — Start from the reference. Copy html/splash-default/ to a new folder (or just edit it).
Its three files are the whole template:
index.html— markup: the brand lockup, a progress bar, a status line, a footer/Discord link.style.css— all look-and-feel; it self-hosts its fonts underfonts/(FiveM has no guaranteed internet, so bundle every asset — never hot-link).app.js— the render logic: a fewRfxSplash.on(...)handlers and a finalRfxSplash.ready().
2 — Reskin freely, keep the wiring. Change the markup, palette, fonts, motif, copy — anything
visual. The only things to preserve are the RfxSplash calls in app.js: subscribe to
progress/status/init, write them into your DOM, and call ready() at the end. Pull
rfx-splash.js in before your app.js:
<script src="../rfx-splash.js"></script> <!-- host SDK: one copy per splash -->
<script src="app.js"></script> <!-- your render logic -->3 — Declare your assets. Every file a splash uses must be listed in the resource's
fxmanifest.lua files { … } block, or FiveM won't serve it to the loadscreen NUI:
files {
'html/my-splash/index.html',
'html/my-splash/style.css',
'html/my-splash/app.js',
'html/my-splash/fonts/*.woff2',
}4 — Point the host at it. In Phase 1 the host mounts a fixed splash — the iframe src in
html/host.html is hardcoded to splash-default/index.html. To run your own folder today, change
that one attribute:
<iframe id="stage" class="stage" title="RivalityFX"
sandbox="allow-scripts allow-same-origin"
src="my-splash/index.html"></iframe> <!-- was: splash-default/index.html -->That's the whole loop: ensure loadscreen, connect, watch your splash drive the real load. The
default stays in the resource as your hard reference (and the fallback content).
Keep the default as the floor. Don't delete
splash-default/— even if you stop pointing the host at it, it's the brand-true reference and the hard fallback's spiritual twin. Fork it, don't gut it.
Brand matching (theme + Discord)
A splash gets two pieces of live, server-owned data on init, so a third-party splash can match
this server instead of guessing:
-
theme— brand tokens mirroring the design kit:{ ink, paper, team1, team2, accent, fontDisplay, fontUi }. Currently the host hardcodes these (Phase 2+ may source them from convars). Read them in yourinithandler to tint a generic splash, or — like the default — just bake the same palette into your CSS and ignore the tokens. -
discord— the live invite. It comes from therfx_discordadmin convar (server.cfg→setr rfx_discord …, replicated to clients —setsalone would not reach client scripts), the same source as the in-game menu and the website.client.luapushes it into the host during the load phase; the host forwards it oninit. Wire it onto your link, and hide the link if it's empty:RfxSplash.on("init", function (d) { if (d.discord) discord.href = d.discord; else discord.style.display = "none"; // no invite configured → no dead link });The default splash also carries a hardcoded fallback invite in its markup, so the link still works if the config message races the page. Override
rfx_discord, not the HTML, to change it for real.
The hard fallback (the floor)
The host's fallback block is the floor: pure inline brand, system fonts, zero external
asset, its own moving progress bar. It is shown immediately, kept until a splash signals ready,
and restored if a splash fails. This is the guarantee that a player is never stuck on a blank
or broken loading screen — design your splash assuming the fallback has your back, and call
fail() rather than limping along when a required asset is missing.
The fallback lives in host.html + host.css and is deliberately minimal. If you rebrand the
whole resource, give it a quick pass too (the wordmark and the two-stop bar gradient) so the
worst case still looks like your server — but keep it asset-free.
What's shipped vs coming
Be honest about the seams — the platform ships in phases.
- Phase 1 (shipped, today). Host + protocol +
RfxSplashSDK + the bundled default. You customize by reskinning the default or by copying it and repointing the host iframe (above). One splash, chosen at the source. - Phase 2 (coming). A prebuild pool: installable splash packs register through an SDK
contract (
defineSplash) and the host swaps its iframesrcto a splash picked from the pool — no host edit. Theservertoken and convar-sourcedthemeland here too. - Phase 3 (coming). Third-party bundles: the host tightens the iframe
sandbox(dropsallow-same-origin) so untrusted splashes can run isolated — the groundwork for distributing and selling splashes the way cosmetic packs work today.
Until Phase 2, treat a splash as an edit to the loadscreen resource rather than a standalone
installable — the protocol and RfxSplash API are stable, so a splash you build now carries
forward unchanged when the pool arrives.
See also
- Creating for RivalityFX — the platform-wide creator model (every creation is a resource).
- Server configuration — enabling
loadscreenandensureorder. - Selling VIP & packs — the sale model splash packs will join in Phase 3.
html/SPLASH-API.md(in the resource) — the canonical protocol reference.