Why Build a Custom Scoreboard
The default FiveM player list is functional but lacks the polish and information density that roleplay servers need. A custom scoreboard lets you display player names alongside their in-character identity, job title, server ID, and connection quality, all styled to match your server's branding. Beyond aesthetics, a custom scoreboard gives you control over what information is visible to different player groups. Regular players might see character names and IDs, while admins see additional fields like Steam identifiers and ping values. Building your own also means you can add features like sorting, filtering by job, and real-time player count statistics that help both players and staff manage the server effectively.
Resource Structure and Manifest
A scoreboard resource is relatively lightweight, consisting of a client script for keybind handling and data collection, a server script for aggregating player data, and an NUI page for rendering the visual display. Keeping the resource self-contained with no external dependencies beyond your framework makes it easy to install and maintain. Here is the resource manifest and folder structure:
-- fxmanifest.lua
fx_version 'cerulean'
game 'gta5'
description 'Custom Scoreboard System'
author 'TDYSKY'
version '1.0.0'
ui_page 'html/index.html'
client_scripts {
'client/main.lua',
}
server_scripts {
'server/main.lua',
}
files {
'html/index.html',
'html/style.css',
'html/script.js',
}
lua54 'yes'
The ui_page directive points to the NUI entry file, and the files block ensures that all NUI assets are bundled with the resource. Using lua54 enables Lua 5.4 features like integer division and bitwise operators, which can be useful for formatting and data packing in more advanced scoreboard implementations.
Server-Side Player Data Collection
The server is responsible for building the complete player list with all the information the scoreboard needs to display. Rather than having the client query each player individually, the server collects all player data into a single table and sends it to the requesting client in one payload. This approach scales well because the data aggregation happens once per request rather than once per player. Include ping values, job information, and player identifiers in the payload:
-- server/main.lua
local QBCore = exports['qb-core']:GetCoreObject()
QBCore.Functions.CreateCallback('scoreboard:server:getPlayers', function(source, cb)
local players = {}
local srcPlayer = QBCore.Functions.GetPlayer(source)
local isAdmin = srcPlayer and IsPlayerAceAllowed(source, 'admin')
for _, playerId in ipairs(GetPlayers()) do
local Player = QBCore.Functions.GetPlayer(tonumber(playerId))
if Player then
local playerData = {
serverId = playerId,
name = Player.PlayerData.charinfo.firstname .. ' ' ..
Player.PlayerData.charinfo.lastname,
job = Player.PlayerData.job.label or 'Unemployed',
jobGrade = Player.PlayerData.job.grade.name or '',
onDuty = Player.PlayerData.job.onduty,
ping = GetPlayerPing(playerId),
}
-- Only include sensitive data for admins
if isAdmin then
playerData.steamName = GetPlayerName(playerId)
playerData.identifiers = GetPlayerIdentifiers(playerId)
end
table.insert(players, playerData)
end
end
-- Sort by server ID
table.sort(players, function(a, b)
return tonumber(a.serverId) < tonumber(b.serverId)
end)
cb(players, #players, GetConvar('sv_maxclients', '64'))
end)
The callback checks whether the requesting player has admin permissions before including sensitive information like Steam names and identifiers. This prevents regular players from harvesting account information through the scoreboard. The GetConvar call retrieves the maximum player count so the scoreboard can display a capacity indicator like "32/64 Players".
Client-Side Keybind and Toggle Logic
The scoreboard should open when the player holds a specific key and close when they release it. FiveM provides the RegisterKeyMapping function for rebindable keybinds, which is the preferred approach over hardcoded key checks because it lets players customize their controls through the game settings menu. Use a hold-to-show pattern where the NUI opens on key press and closes on key release for a smooth, non-intrusive experience:
-- client/main.lua
local QBCore = exports['qb-core']:GetCoreObject()
local isOpen = false
local refreshInterval = 5000 -- ms between data refreshes while open
RegisterKeyMapping('+scoreboard', 'Open Scoreboard', 'keyboard', 'HOME')
RegisterCommand('+scoreboard', function()
if isOpen then return end
isOpen = true
RefreshAndShow()
end, false)
RegisterCommand('-scoreboard', function()
if not isOpen then return end
isOpen = false
SetNuiFocus(false, false)
SendNUIMessage({action = 'hide'})
end, false)
function RefreshAndShow()
QBCore.Functions.TriggerCallback('scoreboard:server:getPlayers',
function(players, count, maxPlayers)
SendNUIMessage({
action = 'show',
players = players,
playerCount = count,
maxPlayers = maxPlayers,
serverName = 'My Roleplay Server',
})
end
)
end
-- Auto-refresh while scoreboard is open
CreateThread(function()
while true do
Wait(refreshInterval)
if isOpen then
RefreshAndShow()
end
end
end)
The + and - prefixes on the command names create a press and release pair automatically. When the player presses the bound key, the +scoreboard command fires. When they release it, -scoreboard fires. Notice that SetNuiFocus is called with false, false because the scoreboard is display-only and does not need mouse input. If you want a clickable scoreboard with sorting or filtering, pass true, true instead and add a close button in the NUI.
NUI Rendering with HTML and CSS
The NUI layer renders the player list as a styled table or card layout. Use a semi-transparent backdrop that overlays the game screen without completely blocking the view. CSS Grid or Flexbox works well for aligning the columns of data. Color-code ping values so players can quickly identify connection quality, green for good ping, yellow for moderate, and red for poor. Here is the core NUI structure:
<!-- html/index.html -->
<div id="scoreboard" class="hidden">
<div class="sb-header">
<h2 id="server-name"></h2>
<span id="player-count"></span>
</div>
<div class="sb-columns">
<span>ID</span>
<span>Name</span>
<span>Job</span>
<span>Ping</span>
</div>
<div id="player-list"></div>
</div>
<script>
window.addEventListener('message', (event) => {
const data = event.data;
if (data.action === 'show') {
const sb = document.getElementById('scoreboard');
sb.classList.remove('hidden');
document.getElementById('server-name').textContent = data.serverName;
document.getElementById('player-count').textContent =
data.playerCount + '/' + data.maxPlayers + ' Players';
const list = document.getElementById('player-list');
list.innerHTML = data.players.map(p => {
const pingClass = p.ping < 80 ? 'ping-good' :
p.ping < 150 ? 'ping-warn' : 'ping-bad';
return `<div class="sb-row">
<span class="sb-id">${p.serverId}</span>
<span class="sb-name">${p.name}</span>
<span class="sb-job">${p.job}</span>
<span class="sb-ping ${pingClass}">${p.ping}ms</span>
</div>`;
}).join('');
}
if (data.action === 'hide') {
document.getElementById('scoreboard').classList.add('hidden');
}
});
</script>
Style the scoreboard with a dark, semi-transparent background and fixed positioning so it stays centered on screen. Add a max-height with overflow scrolling for the player list so servers with 200 or more players do not push the scoreboard off screen. Consider adding a subtle fade-in animation using CSS transitions to make the open and close feel polished rather than jarring.
Adding Search and Filter Functionality
For servers with large player counts, searching and filtering the scoreboard becomes essential. Add a search input at the top that filters the player list by name, server ID, or job title as the player types. Implement the filter entirely in JavaScript on the NUI side so there is no server round-trip delay. You can also add clickable column headers for sorting by ID, name, job, or ping:
// html/script.js - Search and sort logic
let currentPlayers = [];
let sortField = 'serverId';
let sortAsc = true;
function renderPlayers(filter = '') {
let filtered = currentPlayers;
if (filter) {
const term = filter.toLowerCase();
filtered = currentPlayers.filter(p =>
p.name.toLowerCase().includes(term) ||
p.serverId.toString().includes(term) ||
p.job.toLowerCase().includes(term)
);
}
filtered.sort((a, b) => {
let valA = a[sortField];
let valB = b[sortField];
if (typeof valA === 'string') {
valA = valA.toLowerCase();
valB = valB.toLowerCase();
}
if (valA < valB) return sortAsc ? -1 : 1;
if (valA > valB) return sortAsc ? 1 : -1;
return 0;
});
const list = document.getElementById('player-list');
list.innerHTML = filtered.map(p => {
const pingClass = p.ping < 80 ? 'ping-good' :
p.ping < 150 ? 'ping-warn' : 'ping-bad';
const dutyDot = p.onDuty
? ''
: '';
return `
${p.serverId}
${p.name}
${dutyDot}${p.job}
${p.ping}ms
`;
}).join('');
document.getElementById('filtered-count').textContent =
filtered.length + ' shown';
}
document.querySelectorAll('.sb-sort').forEach(btn => {
btn.addEventListener('click', () => {
const field = btn.dataset.field;
if (sortField === field) {
sortAsc = !sortAsc;
} else {
sortField = field;
sortAsc = true;
}
renderPlayers(document.getElementById('sb-search').value);
});
});
The sort state is maintained client-side so toggling between ascending and descending order is instant. The duty indicator dot next to the job name provides a quick visual signal for whether a player is currently on-duty, which is especially useful for admins scanning the list to check staffing levels across different departments.
Admin Features and Extended Information
When an admin opens the scoreboard, they should see additional columns and action buttons that regular players do not have access to. Add a right-click context menu on player rows that lets admins kick, ban, teleport to, or spectate a player directly from the scoreboard. This turns the scoreboard from a simple display into a lightweight admin panel. Implement the permission check on both the client and server side. The client checks whether to render admin UI elements, and the server validates every admin action independently:
-- Admin action handler (server)
RegisterNetEvent('scoreboard:server:adminAction', function(targetId, action)
local src = source
if not IsPlayerAceAllowed(src, 'admin') then
DropPlayer(src, 'Unauthorized admin action')
return
end
local target = tonumber(targetId)
if not target or not GetPlayerName(target) then return end
if action == 'kick' then
DropPlayer(target, 'Kicked by admin')
elseif action == 'teleport' then
local targetPed = GetPlayerPed(target)
local coords = GetEntityCoords(targetPed)
TriggerClientEvent('scoreboard:client:teleport', src, coords)
elseif action == 'spectate' then
TriggerClientEvent('scoreboard:client:spectate', src, target)
elseif action == 'freeze' then
local targetPed = GetPlayerPed(target)
FreezeEntityPosition(targetPed, true)
SetTimeout(30000, function()
if DoesEntityExist(targetPed) then
FreezeEntityPosition(targetPed, false)
end
end)
end
end)
Always log admin actions to a database or Discord webhook for accountability. Include the admin's identifier, the target player, the action taken, and a timestamp. This audit trail is essential for resolving disputes between staff members and for detecting compromised admin accounts that might be abusing their powers.
Performance and Optimization
A scoreboard that causes frame drops defeats its purpose. The most common performance mistake is refreshing player data every frame or every few milliseconds. The auto-refresh interval should be no shorter than three to five seconds because player data like ping and job status does not change rapidly enough to justify faster updates. On the NUI side, avoid destroying and recreating the entire DOM on every refresh. Instead, use a diffing approach that only updates the rows that changed, or at minimum, use innerHTML on just the player list container rather than the entire scoreboard. Keep the CSS simple and avoid heavy effects like blur or box-shadow on every row since these trigger expensive GPU compositing operations when multiplied across hundreds of rows. For very large servers with 200 or more players, implement virtual scrolling that only renders the visible rows plus a small buffer, keeping the DOM node count manageable regardless of how many players are connected. Test your scoreboard on a full server to measure the actual performance impact, because a feature that looks fine with 10 players can become a bottleneck at 200.