RivalityFX Docsrivalityfx.com ↗

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 touch postMessage directly.
  • 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)

rfxPayloadWhen
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)

rfxPayloadMeaning
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 initdatamapstart (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
MemberWhat 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.
.themeBrand tokens, once init arrives (see below).
.discordThe live Discord invite, once init arrives.
.serverReserved — 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 calls ready(). Only then does it fade your splash in and start the stream. If you never ready() (or you fail()), the player keeps the fallback — a working, moving progress bar — instead of a blank or half-built page. Call ready() as soon as your DOM can accept the stream; you'll receive a flush of the latest status + progress right 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 under fonts/ (FiveM has no guaranteed internet, so bundle every asset — never hot-link).
  • app.js — the render logic: a few RfxSplash.on(...) handlers and a final RfxSplash.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 your init handler 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 the rfx_discord admin convar (server.cfgsetr rfx_discord …, replicated to clients — sets alone would not reach client scripts), the same source as the in-game menu and the website. client.lua pushes it into the host during the load phase; the host forwards it on init. 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 + RfxSplash SDK + 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 iframe src to a splash picked from the pool — no host edit. The server token and convar-sourced theme land here too.
  • Phase 3 (coming). Third-party bundles: the host tightens the iframe sandbox (drops allow-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