Pacing a loading screen against a real sixty-second join, the status line versus a fake progress bar, and the motion rules that keep it cheap on the client.
10 min read
A loading screen is the easiest thing on a FiveM server to get technically working and the hardest to get right. An HTML file, one line in a manifest, and it renders. The problem is that the bar for working is so low that almost every server clears it, which means the loading screen is not a differentiator because it exists. It is a differentiator because of about eight decisions, none of which are visible as decisions once they are made correctly.
When you test your own loading screen you see it for four seconds, because your cache is warm and your resources are local. A player joining for the first time on a server with three hundred resources sees it for well over a minute, sometimes two. Every design decision should be made against the long case, because the short case is forgiving of anything.
That length changes what belongs on screen. A composition that is pleasant for five seconds can be actively irritating at second forty, and the things that cross over are specific: anything that loops faster than about four seconds, anything that flashes, any animation with a hard reset rather than a seamless return, and any text that cycles. A three-second tip rotation is charming twice and maddening on the fifteenth pass.
What survives a minute is slow continuous motion with no discernible loop point. Drifting cloud layers, a parallax that never repeats within the window, embers rising, a glint that crosses the frame over twelve seconds. The player should be unable to tell you how long the loop is, which means there is effectively nothing to get bored of.
Progress bars on FiveM loading screens are usually fiction. The honest reason is that the client does report progress, but not in a way that maps to a smooth bar: the fraction arrives in uneven jumps and then stops moving entirely during the longest phase, which is resource download and the initial map load. A bar that sits at 73 per cent for forty seconds is worse than no bar, because it tells the player something is wrong.
The common response is to fake it, animating a bar to full over a guessed duration. That is dishonest in a way players notice, because the bar reaches 100 per cent and nothing happens. The better answer is a status line: one line of small type saying what the client is currently doing, driven by the real phase events. It is always truthful, it never stalls visibly because the message changes even when a fraction does not, and it costs nothing to implement.
The packs ship with the progress bar off by default for exactly that reason, and with the real phase messages wired up. If you want a bar, use it as a secondary element, thin and quiet, underneath a status line that is doing the actual communicating.
The client posts messages into the loading screen page as it works through startup. You listen on the window and read eventName. Two of them carry useful data and the rest are phase markers, which is to say they tell you where in the sequence the client is and nothing more. That is enough for a status line.
| Event | What the client is doing | A line that is honest |
|---|---|---|
| startInitFunction | Beginning an initialisation stage | INITIALISING |
| startDataFileEntries | Queueing data files, with a count | READING DATA FILES |
| performMapLoadFunction | Streaming and building the map | BUILDING THE MAP |
| startInitFunctionOrder | Working through the init order | STARTING UP |
| initFunctionInvoking | Running resource init functions | LOADING RESOURCES |
| endInitFunction | Finishing an initialisation stage | ALMOST THERE |
| loadProgress | Reporting loadFraction, 0 to 1 | Feed a bar, not a line |
var MSG = {
startInitFunction: "INITIALISING",
startDataFileEntries: "READING DATA FILES",
performMapLoadFunction: "BUILDING THE MAP",
startInitFunctionOrder: "STARTING UP",
initFunctionInvoking: "LOADING RESOURCES",
onLogLine: null,
endInitFunction: "ALMOST THERE"
};
window.addEventListener("message", function (e) {
var d = e.data || {};
if (d.eventName === "loadProgress" && typeof d.loadFraction === "number") {
window.__progress = d.loadFraction;
return;
}
if (d.eventName && Object.prototype.hasOwnProperty.call(MSG, d.eventName)) {
var m = MSG[d.eventName];
if (m) window.__loadMsg = m;
}
});onLogLine is mapped to null deliberately. It fires constantly with raw client log lines, and putting those on screen is a specific kind of mistake: it looks like debugging output because it is debugging output. Consume the event so it does not fall through to a default handler, and show nothing.
A note on wording. Keep the lines short, keep them in one voice, and do not write them in the first person on behalf of the server. 'BUILDING THE MAP' is fine. 'We are building the map for you!' is not, because it is six words to say one thing and because the exclamation mark will be on screen for forty seconds.
Three short badges work. Something like FIVEM RP, 18+ WHITELIST, EU SERVER: a row of small capitalised labels that tell a player what kind of place they have joined and reinforce it on every join. They are static, they cost nothing, and they are the only element on a loading screen doing an informational job rather than a decorative one.
Tip lines are a different matter and they default to off for a reason. A single tip, static, genuinely useful, is fine: a keybind they will need in the first minute, for instance. What does not work is a rotating list of twenty tips, because the player reads one, starts the second, and the text changes under them. If you must rotate, rotate once per join rather than on a timer, so each player sees one tip and it is a different one next time.
Where badges become noise is when they stop being information. Four badges is one too many. Badges carrying marketing claims rather than facts are noise. A badge saying CUSTOM SCRIPTS is noise, because every server says it and no player has ever chosen a server on the basis of it. The test is whether a new player could act differently as a result of reading it.
A loading screen with music is a real improvement and shipping it on by default is still wrong, for two reasons. The first is technical: browser engines block autoplay with sound until the user has interacted with the page, and the loading screen is a browser engine. An audio element whose play call is rejected leaves you with silence and a promise rejection in the console, which looks like a broken file rather than a policy decision.
The second is that the player may already have something playing. A sizeable share of people joining your server are listening to music, in a Discord call, or watching the streamer whose server this is, and overriding that at full volume is the single most intrusive thing a loading screen can do. Setting it off, leaving it as two lines to enable, and keeping the volume at roughly a third is the right default.
If you do enable it, attempt playback immediately and fall back to starting on the first click. That works because the manifest declares the cursor visible, so there is something to click with. Remove the listener once it fires, or you get a second overlapping playback the next time anybody clicks.
fx_version 'cerulean'
game 'gta5'
name 'velora_loading'
description 'Velora loading screen'
version '1.0.0'
loadscreen 'index.html'
loadscreen_cursor 'yes'
files {
'index.html',
'style.css',
'scene.js',
'app.js',
'fonts/*.ttf'
}Every file the page references has to appear in files, including the fonts, and the glob is the only sane way to handle a font directory. A missing entry does not error anywhere you will see it; the page simply renders without that asset, which is how people end up with a loading screen in a fallback typeface and no idea why.
loadscreen_cursor 'yes' deserves a thought rather than a copy and paste. With it on, the player has a visible pointer, which lets you put a clickable element on screen and lets the music fallback work. With it off, the screen is cleaner. The reason to keep it on is the music; the reason it sometimes looks wrong is a visible cursor hovering over a page with nothing clickable on it. If you have no interactive element and no audio, turn it off.
Author the composition at 1920x1080 and scale the whole thing with one factor. Not media queries, not separate layouts, one number read from the window and applied to the root, with every size in the stylesheet expressed as a multiple of it.
function fit() {
var s = Math.min(window.innerWidth / 1920, window.innerHeight / 1080);
document.documentElement.style.setProperty("--s", String(s));
stage.style.width = window.innerWidth + "px";
stage.style.height = window.innerHeight + "px";
}
fit();
window.addEventListener("resize", fit);The min of the two ratios is what makes this work everywhere. On 1440p both ratios are above one and the factor is 1.33, so the lockup grows proportionally and the type stays at the same visual size relative to the frame. On a 3440x1440 ultrawide the width ratio is 1.79 and the height ratio is 1.33, so the factor is 1.33 and the extra width becomes background rather than stretching the composition. The stage is sized to the real window separately, so the backdrop still fills the screen.
The failure mode to avoid is using viewport units directly for type. Font sizes in vw look correct at 1080p and then become absurd on an ultrawide, because the width grew by 79 per cent while the height did not grow at all. Anything that should scale with the composition scales with the factor; anything that should fill the screen uses the viewport. Mixing the two is where ultrawide loading screens go wrong.
A loading screen runs at the worst possible moment. The client is streaming assets, decompressing, building collision and saturating a disk, and your page is competing for the same machine. A loading screen that drops to eight frames a second is worse than a still image, and it is entirely a function of what you animate rather than how much.
Animate transform and opacity. Nothing else. Those two are composited on the GPU without touching layout or repainting the element, which is why a dozen layers drifting on transforms costs almost nothing while a single element animating its width costs a layout pass every frame.
The practical version: build the scene from a handful of layers that each translate or fade, keep blurs baked into the artwork rather than applied live, and if something must appear to grow, scale it. A scene built that way holds its frame rate on a machine that is otherwise fully committed, which is the only condition it will ever run under.
Anybody who server-hops recognises the circulating free templates within two seconds, and the recognition is not about quality. It is about a small set of details that nobody bothers to change.
Half of those are fixable in an afternoon on a template you already have, which is worth saying because the choice is not only between a free template and a commission. Delete the dead social icons, remove the fake counter, cut the progress bar or wire it to the real fraction, and set the name in the right typeface. What is left is a loading screen that looks like somebody looked at it, which is the entire claim being made here.