Tutorial 2026-04-15

FiveM Radio & Dispatch System Development

TDYSKY

TDYSKY

Founder & Lead Developer at Agency Scripts

Radio and Dispatch System Architecture

A radio and dispatch system is the communication backbone of emergency services on a FiveM roleplay server. It enables police, EMS, and fire departments to coordinate responses through structured radio channels, receive prioritized dispatch alerts from 911 calls and automated detection systems, and track unit locations in real time through GPS integration. The architecture consists of three interconnected systems: the radio channel framework that manages who can talk on which frequency, the dispatch queue that processes incoming calls and routes them to appropriate units, and the GPS tracking layer that visualizes unit positions on a shared map. Unlike simple chat-based communication, a proper radio system adds the immersion of switching frequencies, hearing radio chatter only when tuned in, and following structured communication protocols that mirror real-world emergency services. The entire system runs through server-side state management to prevent cheating, with client-side NUI providing the radio interface and dispatch panel.

Radio Channel System

Radio channels organize communication so that different departments and units can coordinate without cross-talk. Each channel has a frequency number, access restrictions based on job roles, and a list of currently connected players. The system supports multiple channel types: department-wide channels for general communication, tactical channels for specific operations like a pursuit or bank robbery response, and inter-department channels where police and EMS can coordinate on shared incidents. Players join a channel through a radio item or NUI interface, and all voice or text transmitted on that channel reaches only players tuned to the same frequency. Implement a priority speaker system where dispatch operators and commanding officers can broadcast to all channels simultaneously for emergency alerts:

Config.RadioChannels = {
    -- Police Department
    { frequency = 1, label = 'PD Main',        jobs = {'police'},          type = 'department' },
    { frequency = 2, label = 'PD Patrol',       jobs = {'police'},          type = 'department' },
    { frequency = 3, label = 'PD Tactical',     jobs = {'police'},          type = 'tactical',  maxUsers = 8 },
    { frequency = 4, label = 'PD Detectives',   jobs = {'police'},          type = 'tactical',  minRank = 3 },

    -- EMS / Fire
    { frequency = 10, label = 'EMS Main',       jobs = {'ambulance'},       type = 'department' },
    { frequency = 11, label = 'EMS Field',      jobs = {'ambulance'},       type = 'department' },
    { frequency = 12, label = 'Fire Main',      jobs = {'fire'},            type = 'department' },

    -- Inter-department
    { frequency = 20, label = 'Emergency Joint', jobs = {'police','ambulance','fire'}, type = 'inter' },
    { frequency = 21, label = 'Command Channel', jobs = {'police','ambulance','fire'}, type = 'command', minRank = 5 },

    -- Civilian (if radio item owned)
    { frequency = 50, label = 'Civilian Band',  jobs = {},                  type = 'open' },
}

-- Server-side channel state
local radioState = {} -- [frequency] = { players = {}, priority = false }

RegisterNetEvent('radio:server:joinChannel', function(frequency)
    local src = source
    local Player = QBCore.Functions.GetPlayer(src)
    if not Player then return end

    local channel = nil
    for _, ch in ipairs(Config.RadioChannels) do
        if ch.frequency == frequency then channel = ch break end
    end
    if not channel then return end

    -- Check job access
    if #channel.jobs > 0 then
        local playerJob = Player.PlayerData.job.name
        local hasAccess = false
        for _, job in ipairs(channel.jobs) do
            if job == playerJob then hasAccess = true break end
        end
        if not hasAccess then
            TriggerClientEvent('QBCore:Notify', src, 'No access to this frequency', 'error')
            return
        end
    end

    -- Check rank requirement
    if channel.minRank and Player.PlayerData.job.grade.level < channel.minRank then
        TriggerClientEvent('QBCore:Notify', src, 'Insufficient rank for this channel', 'error')
        return
    end

    -- Check max users for tactical channels
    if channel.maxUsers and radioState[frequency] then
        if #radioState[frequency].players >= channel.maxUsers then
            TriggerClientEvent('QBCore:Notify', src, 'Channel full', 'error')
            return
        end
    end

    -- Leave current channel
    LeaveCurrentChannel(src)

    -- Join new channel
    if not radioState[frequency] then
        radioState[frequency] = { players = {}, priority = false }
    end
    table.insert(radioState[frequency].players, src)

    TriggerClientEvent('radio:client:joined', src, frequency, channel.label)
    TriggerClientEvent('QBCore:Notify', src, 'Tuned to ' .. channel.label .. ' (' .. frequency .. ')', 'success')
end)

10-Code System

Ten-codes are standardized radio shorthand that add authenticity to emergency service communication and enable quick status updates through the dispatch system. Instead of implementing 10-codes as mere chat macros, build them as functional commands that update the officer's status in the dispatch system and trigger automated responses. When an officer calls 10-80 (pursuit in progress), the system should automatically update their status on the dispatch board, create a pursuit alert visible to all units, and begin GPS tracking of the officer's vehicle. When they call 10-97 (arriving on scene), the dispatch system marks them as on-scene for the active call they are responding to. Build the 10-code system as a configurable table so server owners can customize codes to match their department's preferred protocol:

Config.TenCodes = {
    ['10-4']  = { label = 'Acknowledged',           action = nil,                           status = nil },
    ['10-6']  = { label = 'Busy',                    action = nil,                           status = 'busy' },
    ['10-7']  = { label = 'Out of Service',          action = 'setOffDuty',                  status = 'off_duty' },
    ['10-8']  = { label = 'In Service',              action = 'setOnDuty',                   status = 'available' },
    ['10-11'] = { label = 'Traffic Stop',            action = 'createTrafficStop',            status = 'traffic_stop' },
    ['10-15'] = { label = 'Suspect in Custody',      action = nil,                           status = 'transport' },
    ['10-20'] = { label = 'Location Request',        action = 'shareLocation',               status = nil },
    ['10-23'] = { label = 'Arrived at Scene',        action = 'markOnScene',                 status = 'on_scene' },
    ['10-32'] = { label = 'Person with Weapon',      action = 'createAlert',                 status = 'responding', priority = 'high' },
    ['10-41'] = { label = 'Beginning Tour of Duty',  action = 'clockIn',                     status = 'available' },
    ['10-42'] = { label = 'Ending Tour of Duty',     action = 'clockOut',                    status = 'off_duty' },
    ['10-71'] = { label = 'Shooting',                action = 'createAlert',                 status = 'responding', priority = 'critical' },
    ['10-78'] = { label = 'Officer Needs Assistance', action = 'panicButton',                status = 'emergency', priority = 'critical' },
    ['10-80'] = { label = 'Pursuit in Progress',     action = 'startPursuit',                status = 'pursuit', priority = 'high' },
    ['10-97'] = { label = 'Arriving on Scene',       action = 'markOnScene',                 status = 'on_scene' },
    ['10-99'] = { label = 'Officer Down',            action = 'officerDown',                 status = 'emergency', priority = 'critical' },
    ['code4'] = { label = 'No Further Assistance',   action = 'clearScene',                  status = 'available' },
}

RegisterNetEvent('radio:server:tenCode', function(code)
    local src = source
    local Player = QBCore.Functions.GetPlayer(src)
    if not Player then return end

    local codeConfig = Config.TenCodes[code]
    if not codeConfig then return end

    -- Update officer status
    if codeConfig.status then
        UpdateUnitStatus(src, codeConfig.status)
    end

    -- Execute associated action
    if codeConfig.action then
        ExecuteCodeAction(codeConfig.action, src, codeConfig)
    end

    -- Broadcast to channel
    local channel = GetPlayerChannel(src)
    if channel then
        local name = GetUnitCallsign(src) or Player.PlayerData.charinfo.firstname
        BroadcastToChannel(channel, {
            type = 'tencode',
            code = code,
            label = codeConfig.label,
            unit = name,
            priority = codeConfig.priority,
        })
    end
end)

Dispatch Call Queue

The dispatch queue is the central hub that processes all incoming emergency calls and routes them to the appropriate units. When a civilian calls 911, when an automated alarm triggers at a bank or store, or when a police officer requests backup, the system creates a dispatch entry with a priority level, location, description, and the type of service needed. The queue displays all active calls sorted by priority on the dispatch NUI panel, showing the call time, location, status, and which units are assigned. A dedicated dispatch operator role can manually assign calls to specific units, or officers can self-assign by clicking a call and marking themselves as responding. Implement automatic call escalation where unanswered calls increase in priority after a configurable timeout, ensuring that no emergency goes ignored during busy periods:

local dispatchQueue = {}
local callIdCounter = 0

function CreateDispatchCall(data)
    callIdCounter = callIdCounter + 1

    local call = {
        id = callIdCounter,
        type = data.type or 'general',          -- police, ems, fire, general
        priority = data.priority or 'standard', -- low, standard, urgent, critical
        code = data.code or nil,                -- e.g., '10-71', '211' (robbery)
        title = data.title,
        description = data.description or '',
        coords = data.coords,
        street = GetStreetName(data.coords),
        blip = data.blip or nil,
        caller = data.caller or 'Unknown',
        callerId = data.callerId or nil,
        timestamp = os.time(),
        status = 'pending',                     -- pending, assigned, responding, on_scene, resolved
        assignedUnits = {},
        escalationTimer = nil,
        metadata = data.metadata or {},
    }

    -- Set escalation timer
    if call.priority ~= 'critical' then
        call.escalationTimer = os.time() + Config.EscalationTimeout
    end

    table.insert(dispatchQueue, call)

    -- Notify appropriate department
    local targetJobs = GetJobsForCallType(call.type)
    local players = QBCore.Functions.GetQBPlayers()

    for _, player in pairs(players) do
        local job = player.PlayerData.job
        if job.onduty and TableContains(targetJobs, job.name) then
            TriggerClientEvent('dispatch:client:newCall', player.PlayerData.source, call)
        end
    end

    -- Play alert sound based on priority
    if call.priority == 'critical' then
        PlayDispatchAlert('critical', targetJobs)
    end

    return call.id
end

-- Automated dispatch triggers
AddEventHandler('banking:robberyStarted', function(bankId, coords)
    CreateDispatchCall({
        type = 'police',
        priority = 'critical',
        code = '211',
        title = 'Bank Robbery in Progress',
        description = 'Silent alarm triggered at ' .. GetBankLabel(bankId),
        coords = coords,
        blip = { sprite = 500, color = 1, scale = 1.5, flash = true },
    })
end)

AddEventHandler('hospital:911call', function(callerId, coords, description, injuries)
    CreateDispatchCall({
        type = 'ems',
        priority = injuries and injuries.maxSeverity >= 80 and 'critical' or 'urgent',
        title = 'Medical Emergency',
        description = description,
        coords = coords,
        callerId = callerId,
        caller = GetPlayerName(callerId),
        blip = { sprite = 153, color = 3, scale = 1.2 },
        metadata = { injuries = injuries },
    })
end)

GPS Tracking and Unit Map

GPS tracking provides real-time visibility into where all active units are positioned across the map, enabling dispatchers and commanding officers to make informed assignment decisions. Each on-duty officer's position is broadcast to the dispatch system at a configurable interval, typically every 5-10 seconds, and displayed on a shared NUI map panel accessible to dispatch operators and supervisors. The map shows unit blips color-coded by department and status, with icons indicating whether they are available, responding, on-scene, or in pursuit. Include a trail feature that tracks unit movement over the last few minutes so dispatchers can see the direction of travel during pursuits. Implement call radius visualization that draws a circle around active dispatch calls showing the recommended response area. When a dispatcher needs to find the closest available unit to a new call, the system can calculate distances from all available units and suggest the optimal assignment. Keep GPS broadcasting efficient by only sending position updates when the unit has moved more than a minimum distance threshold to reduce network traffic on busy servers.

Alert System and Panic Button

The alert system handles high-priority notifications that demand immediate attention from all units. The most critical alert is the panic button, activated when an officer calls 10-78 or 10-99, which sends their GPS location to all on-duty units with a flashing blip and a distinctive alarm sound. The panic alert should override normal dispatch queue priority and display prominently on every officer's screen until acknowledged. Implement different alert tiers that trigger escalating responses: a standard alert creates a dispatch call with a notification sound, an urgent alert flashes the dispatch panel and plays a warning tone, and a critical alert triggers a full-screen notification with a siren sound and automatic GPS routing to the alert location. Automated alerts integrate with other server systems so that gunshot detection zones trigger alerts when weapons are fired in public areas, speed cameras flag vehicles exceeding speed limits, and store alarm systems create dispatch calls when robberies begin. Each alert type has configurable cooldowns to prevent spam from repeated triggers in the same area.

Dispatch Operator Role

The dispatch operator is a dedicated role that sits at a workstation and manages the flow of emergency calls to field units. Unlike officers who see a simplified dispatch notification, the operator has a full dispatch console NUI with a queue manager, unit roster, live map, and communication tools. The operator can prioritize calls by dragging them in the queue, assign specific units to calls based on proximity and availability, mark calls as resolved when units report clear, and broadcast messages to all units on a department frequency. Implement a workstation system where dispatch operators must be seated at a designated dispatch desk to access the full console, preventing them from operating the dispatch system while in the field. The operator role should have its own job grade progression where experienced dispatchers gain access to inter-department coordination tools and emergency broadcast capabilities. Track dispatch performance metrics like average response time, calls handled per shift, and call resolution rate to provide operators with feedback on their performance and give server administrators data for staffing decisions.

Voice Radio Integration

For servers using voice chat solutions like pma-voice or mumble-voip, integrate the radio system with proximity voice to create realistic radio communication. When a player transmits on a radio channel by holding a keybind, their voice should be heard by all players on the same frequency regardless of physical proximity, with a radio filter effect applied to distinguish radio communication from face-to-face conversation. Implement a squelch system where players must release their transmit key before another player can speak, simulating real radio half-duplex behavior. Add radio sound effects for key-up and key-down to provide audio feedback when someone starts and stops transmitting. The voice integration should respect the channel access controls defined in the radio system, so players who have not joined a frequency through the radio NUI cannot hear or transmit on it even if they somehow know the mumble channel ID. For servers without voice chat, fall back to a text-based radio system where messages sent on a frequency appear in a styled chat format with the sender's callsign and channel identifier.

Integration and Extensibility

Your radio and dispatch system should provide a clean API that other resources can use to create dispatch calls, send alerts, and query unit status without needing to understand the internal implementation. Export functions like CreateDispatchCall, SendAlert, GetAvailableUnits, and GetUnitStatus so that any resource on the server can integrate with the dispatch system. When a bank robbery starts, the banking resource calls the dispatch export to create a call. When EMS receives a 911 call, the hospital resource routes it through the same dispatch system. This centralized approach ensures that all emergency communication flows through a single system with consistent priority handling, unit tracking, and logging. Store dispatch call history in the database for admin review and generate shift reports that summarize call volume, response times, and unit activity for each duty period. Connect the dispatch system to Discord webhooks so that critical alerts like officer-down calls or bank robberies appear in a dedicated staff channel, keeping server administrators informed even when they are not in-game.

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.