Tutorial 2026-04-28

Building a Custom Scoreboard for FiveM

TDYSKY

TDYSKY

Founder & Lead Developer at Agency Scripts

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.

Share this article

Ready to upgrade your server?

Check out our premium FiveM scripts in the Agency Scripts store or join our Discord community for support and updates.