What goes in fxmanifest.lua, why a missing files entry gives you a black screen on the live server, and how to wire a progress bar to FiveM's real loadProgress events.
6 min read
A loading screen is the longest uninterrupted look anybody gets at your server's branding. On a server with a few hundred resources, a first join is a minute or more of a player staring at one screen with nothing else to do. It is also the easiest part of a server to get structurally wrong, because the mechanism is a resource rather than a setting and the failure mode is a black screen with nothing in the console.
A loading screen is an ordinary FiveM resource whose manifest declares a page for the client to render during load. A minimal one looks like this:
velora_loading/
fxmanifest.lua
index.html
style.css
scene.js
app.js
fonts/
Anton-Regular.ttf
SairaCondensed-Bold.ttfThere is no server-side script and no client-side script in the Lua sense. The entire thing is a web page the client renders full screen, plus a manifest telling the client that is what it is.
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'
}Three directives do the work. loadscreen names the page the client renders while the server loads. loadscreen_cursor 'yes' gives the player a mouse cursor, which you need if there is anything clickable on the screen such as a Discord link. And the files block lists every asset the client is allowed to fetch.
Then one line in server.cfg:
ensure velora_loadingUse ensure rather than start. ensure starts the resource and restarts it if it was already running, which is what you want while you are iterating. The resource folder name has to match the name in the manifest, or at least has to be what you write in server.cfg; a mismatch between the folder and the ensure line is the second most common cause of nothing happening.
Most loading screens sold for FiveM are a full-screen video. It is the easy thing to build and it is worse on every measure that matters.
A 1080p loop at any reasonable quality is twenty to forty megabytes. Every player downloads that before they see anything, on their first join and after every cache clear, which means the loading screen makes the loading longer. It plays at whatever frame rate it was encoded at, usually 30, on a monitor running at 144. And it is a fixed 16:9 raster, so everybody on an ultrawide gets it stretched or pillarboxed.
A live page is a few hundred kilobytes of CSS and JavaScript plus the fonts. It renders at the client's native resolution and refresh rate. It adapts to any aspect ratio. And because it is a page, the progress bar can be real rather than a fake timer.
The scaling mechanism is one line of arithmetic. Author the composition at 1920x1080, express every size in the stylesheet in terms of a single custom property, and set that property from the window dimensions:
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);Everything in the stylesheet then reads like calc(72px * var(--s)), and the layout is proportionally identical at 1280x720 and 3440x1440.
FiveM posts messages into the loading screen page as the load progresses. Listening for them is what turns a decorative screen into one that tells a player the server has not hung. The events arrive as window messages with an eventName field.
| eventName | Phase |
|---|---|
| startInitFunction | The client has begun initialising |
| startDataFileEntries | Reading data files; carries a count field |
| performMapLoadFunction | Building the map |
| startInitFunctionOrder | Working through the init order |
| initFunctionInvoking | Loading resources |
| onLogLine | A log line; useful for debugging, too noisy to display |
| endInitFunction | Initialisation finishing |
Separately, a loadProgress event carries a loadFraction field between 0 and 1. That is the number to drive a progress bar with. Mapping the phase events to short human status lines and reading loadFraction for the bar gives you something that tracks the real load:
var MSG = {
startInitFunction: "INITIALISING",
startDataFileEntries: "READING DATA FILES",
performMapLoadFunction: "BUILDING THE MAP",
startInitFunctionOrder: "STARTING UP",
initFunctionInvoking: "LOADING RESOURCES",
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 && MSG[d.eventName]) {
window.__loadMsg = MSG[d.eventName];
}
});Write the values to variables the render loop reads rather than touching the DOM inside the listener. The messages arrive in bursts, and a layout triggered on each one is a visible stutter at exactly the moment the client is busiest.
A word on whether to show a progress bar at all. A bar that tracks the real fraction is reassuring. A bar that is secretly a twenty-second animation is worse than no bar, because it finishes before the load does and the player concludes the server is stuck. If you are not wiring it to loadProgress, leave it off and show a status line instead.
Music on a loading screen divides people, and the correct default is off. A first-time player alt-tabbing out of your server because it started playing a track at full volume is a real cost, and plenty of players have Discord audio running.
If you do want it, three things. Keep it quiet, around a third of full volume. Loop it, because load times vary. And handle the autoplay block: browsers and CEF refuse to start audio without a user gesture, so attempt play, catch the rejection and retry on the first click.
var a = document.getElementById("bgm");
a.src = "music.mp3";
a.volume = 0.35;
var start = function () {
a.play().catch(function () {});
document.removeEventListener("click", start);
};
a.play().catch(function () { document.addEventListener("click", start); });And add 'music.mp3' to the files block. This is the single most common place the files block bites, because the screen worked fine until you added audio.
Point five is worth making a habit of. A loading screen is a web page, so you can develop it entirely in a browser with devtools open, feeding it fake loadProgress messages from the console, and only put it on the server once it already works. That turns a cycle of restart-the-server-and-rejoin into a page refresh.
The loading screen runs while the client is doing the heaviest work it ever does, so anything expensive on the page competes with the thing the player is waiting for. Two rules keep it cheap.
First, animate with transforms and opacity only. A keyframe that moves an element with translate costs the compositor almost nothing; one that changes width, top or box-shadow forces a layout or a paint on every frame. Second, do not run a requestAnimationFrame loop that recalculates geometry. Set up the scene once, let CSS animations run it, and keep the JavaScript to reading the load events.
A loading screen built that way holds a steady frame rate on a machine that is otherwise saturated, which is the only performance measurement that counts here. A video loop does not, because decoding competes for the same budget.