TIMMYG Studios

Setup & Configuration

Complete installation and configuration guide for Tommy's Radio system.

Quick SetupAdvanced ConfigFramework Integration

Prerequisites

  • FiveM server
  • Access to firewall or hosting panel
  • An available port for the voice server (default: 7777)

Installation

1. Extract Files

  1. Download the radio resource
  2. Extract to your server's resources folder
  3. Keep the folder named radio

Resource Name

The resource must be named radio for exports to work correctly.

2. Configure Settings

Edit config.lua with your basic settings:

config.lua
Config = {
    serverPort = 7777,                       -- Port for voice server (must be unused)
    authToken = "CHANGE_ME_TO_RANDOM_TEXT",  -- Security key — you create this
    dispatchNacId = "CHANGE_ME",             -- Dispatch panel password — you choose this

    controls = {
        talkRadioKey = "B",
        toggleRadioKey = "F6",
    },

    voiceVolume = 65,
    sfxVolume = 35,
}

Understanding authToken and dispatchNacId

authToken — A secret string that secures communication between clients and your voice server. Pick any random string (e.g., "Phoenix_PD_Token_2024!"). Change it from the default before going live.

dispatchNacId — The password used to log into your dispatch panel. You choose this (e.g., "141", "DISPATCH2024"). Anyone who knows it can access your dispatch panel.

3. Firewall Setup

The radio voice server needs its own dedicated port, separate from FiveM (30120) and txAdmin (40120).

Port Requirements

Your server needs three separate ports:

  1. FiveM (usually 30120) — game server
  2. txAdmin (usually 40120) — management panel
  3. Radio (e.g., 7777) — voice server and dispatch panel

You cannot reuse existing ports. This is the most common setup issue.

Web Panel Hosting (Pterodactyl / Most Providers)

  1. In your hosting panel, find Network, Ports, or Allocations
  2. Add a new port allocation — the panel will assign a number (e.g., 50978)
  3. Update config.lua:
config.lua
Config = {
    serverPort = 50978,
    serverAddress = "http://YOUR_SERVER_IP:50978/",
}
  1. Restart your entire server from the panel (not just the resource)
  2. Verify at portchecker.co — enter your IP and port

Port Still Closed?

Many Pterodactyl-based hosts (Gravel Host, RocketNode, VibeGames, etc.) require a support ticket to actually open the port, even after adding it in the panel.

Tell them: "I need port [YOUR_PORT] opened for a custom voice server resource. The port is added in my panel but shows closed on port checkers."

VPS / Dedicated Server

Open the port directly, then leave serverAddress blank — the script auto-detects your IP.

Ubuntu/Debian:

sudo ufw allow 7777

CentOS/RHEL:

sudo firewall-cmd --permanent --add-port=7777/tcp
sudo firewall-cmd --reload

Windows Server:

netsh advfirewall firewall add rule name="Radio Voice Server" dir=in action=allow protocol=TCP localport=7777
config.lua
Config = {
    serverPort = 7777,
    serverAddress = "",  -- Auto-detect
}

Home Server / Port Forwarding

Forward port 7777 (TCP) in your router settings (similar to a Minecraft server). Use your public IP in serverAddress.

Common Mistakes

  • Using port 30120 or 40120 — already in use by FiveM/txAdmin
  • Mismatched portsserverPort and serverAddress must use the same port
  • Testing before resource starts — the port only opens when the radio resource is running
  • Wrong serverAddress format — must be "http://IP:PORT/" with http:// and trailing /
  • Assuming panel = opened — adding a port in your hosting panel doesn't always open it; verify with portchecker.co

4. Start Resource

Add to server.cfg:

server.cfg
start radio

5. Setup Dispatch Panel

Access your panel at http://yourserverip:yourport/ after the resource starts.

Download available on the main page.

Configure Your Endpoint

The app defaults to the demo server. You must change the endpoint to your own server.

  1. Open app and login with 141 (demo default)
  2. Click the settings cog (⚙️) at top-right
  3. Set Endpoint URL to http://YOUR_IP:YOUR_PORT/
  4. Save — app refreshes
  5. Login with your dispatchNacId

Web Browser

Browsers block microphone access on HTTP. Options:

  • Desktop app (recommended) — bypasses this limitation
  • Chrome flag — enable unsafely-treat-insecure-origin-as-secure for development
  • HTTPS — set up SSL with a reverse proxy (Nginx/Apache) for production

Discord Authentication (Optional)

Enable Discord OAuth instead of the NAC ID password system.

  1. Set useDiscordAuth = true in config.lua
  2. Create server/.env (copy from server/.env.example):
server/.env
DISCORD_CLIENT_ID="your_discord_client_id"
DISCORD_SECRET="your_discord_secret"
DISCORD_GUILD_ID="your_discord_guild_id"
DISCORD_ROLES=""              # Comma-separated Role IDs, or "" for all members
DISCORD_REDIRECT_URI=""       # Leave empty unless using a reverse proxy/domain
  1. Get credentials from the Discord Developer Portal
  2. Add a redirect URI to your app's OAuth2 settings: http://your-server-url:port/radio/dispatch/auth

Discord IDs

Enable Developer Mode in Discord (User Settings → Advanced) to copy Server/Role IDs by right-clicking. Leave DISCORD_ROLES empty to allow all server members.


Framework Integration

NAC ID System

NAC IDs (Network Access Codes) control which zones and channels a player can access. Think of them as roles — a string label like "141" assigned based on job or department.

How NAC IDs Work

  • Police get NAC ID "141" → access police zones and channels
  • EMS get NAC ID "200" → access medical zones and channels
  • A channel with allowedNacs = { "141" } → only police can connect
  • A zone with nacIds = { "141", "200" } → visible to both police and EMS

Core functions in config.lua:

config.lua
-- Control who can use the radio (server-side)
Config.radioAccessCheck = function(playerId)
    return true  -- Default: allow everyone
end

-- Assign NAC IDs based on job/role (server-side)
Config.getUserNacId = function(serverId)
    return "141"  -- Default: everyone gets "141"
end

-- Display name for radio and dispatch panel (server-side)
Config.getPlayerName = function(serverId)
    return GetPlayerName(serverId)
end

When a player's job changes, refresh their NAC ID:

exports['radio']:refreshNacId(serverId)

Framework Examples

QBCore

Radio Item (Optional)

Add to qb-core/shared/items.lua if you want to require a radio item:

qb-core/shared/items.lua
['radio'] = {
    ['name'] = 'radio',
    ['label'] = 'Radio',
    ['weight'] = 1000,
    ['type'] = 'item',
    ['image'] = 'radio.png',
    ['unique'] = false,
    ['useable'] = true,
    ['shouldClose'] = true,
    ['combinable'] = nil,
    ['description'] = 'Handheld radio transceiver'
},
config.lua — QBCore Integration
-- Require radio item
Config.radioAccessCheck = function(playerId)
    local Player = exports['qb-core']:GetPlayer(playerId)
    if not Player then return false end
    local radioItem = Player.Functions.GetItemByName("radio")
    return radioItem and radioItem.amount > 0 or false
end

-- Job-based access (alternative — no item required)
-- Config.radioAccessCheck = function(playerId)
--     local player = exports['qb-core']:GetPlayer(playerId)
--     if player and player.PlayerData.job then
--         local job = player.PlayerData.job.name
--         return job == "police" or job == "ambulance" or job == "ems"
--     end
--     return false
-- end

-- NAC ID by job
Config.getUserNacId = function(serverId)
    local player = exports['qb-core']:GetPlayer(serverId)
    if player and player.PlayerData.job then
        local job = player.PlayerData.job.name
        if job == "police" then return "141"
        elseif job == "ambulance" or job == "ems" then return "200"
        elseif job == "firefighter" then return "300"
        end
    end
    return "0"
end

-- Display name: callsign → lastname → FiveM name
Config.getPlayerName = function(serverId)
    local player = exports['qb-core']:GetPlayer(serverId)
    if player and player.PlayerData then
        if player.PlayerData.metadata.callsign then
            return player.PlayerData.metadata.callsign
        end
        if player.PlayerData.charinfo.lastname then
            return player.PlayerData.charinfo.lastname
        end
    end
    return GetPlayerName(serverId)
end

-- Make radio useable from inventory (optional, add to bottom of config.lua)
if IsDuplicityVersion() then
    exports['qb-core']:CreateUseableItem('radio', function(source)
        TriggerClientEvent('radio:use', source)
    end)
else
    RegisterNetEvent('radio:use', function()
        exports['radio']:openRadio()
    end)
end

ESX

Radio Item (Optional)

Add to your database if you want to require a radio item:

Database
INSERT INTO `items` (`name`, `label`, `weight`, `rare`, `can_remove`) VALUES
('radio', 'Radio', 1, 0, 1);
config.lua — ESX Integration
ESX = exports['es_extended']:getSharedObject()

-- Require radio item
Config.radioAccessCheck = function(playerId)
    local xPlayer = ESX.GetPlayerFromId(playerId)
    if not xPlayer then return false end
    local radioItem = xPlayer.getInventoryItem('radio')
    return radioItem and radioItem.count > 0 or false
end

-- NAC ID by job
Config.getUserNacId = function(serverId)
    local xPlayer = ESX.GetPlayerFromId(serverId)
    if not xPlayer then return "0" end
    local job = xPlayer.getJob()
    if job.name == "police" then return "141"
    elseif job.name == "ambulance" then return "200"
    elseif job.name == "fire" or job.name == "firefighter" then return "300"
    end
    return "0"
end

-- Display name
Config.getPlayerName = function(serverId)
    if not serverId or serverId <= 0 then return "DISPATCH" end
    local xPlayer = ESX.GetPlayerFromId(serverId)
    if not xPlayer then return GetPlayerName(serverId) end

    local callsign = xPlayer.getMeta("callsign")
    if callsign and callsign ~= "" then return callsign end

    local characterName = xPlayer.getName()
    if characterName and characterName ~= "" then
        local lastName = string.match(characterName, "%s+(%S+)$")
        if lastName then return lastName end
        return characterName
    end
    return GetPlayerName(serverId)
end

-- Make radio useable from inventory (optional, add to bottom of config.lua)
if IsDuplicityVersion() then
    ESX.RegisterUsableItem('radio', function(playerId)
        TriggerClientEvent('radio:use', playerId)
    end)
else
    RegisterNetEvent('radio:use', function()
        exports['radio']:openRadio()
    end)
end

Qbox (QBX Core)

Radio Item (Optional)

Add to ox_inventory/data/items.lua if you want to require a radio item:

ox_inventory/data/items.lua
['radio'] = {
    label = 'Radio',
    weight = 500,
    stack = false,
    close = true,
    client = {
        event = 'radio:use',
    },
},
config.lua — Qbox Integration
-- Require radio item (ox_inventory)
Config.radioAccessCheck = function(playerId)
    local count = exports.ox_inventory:Search(playerId, 'count', 'radio')
    return count and count > 0 or false
end

-- NAC ID by job
Config.getUserNacId = function(serverId)
    local player = exports.qbx_core:GetPlayer(serverId)
    if player and player.PlayerData.job then
        local job = player.PlayerData.job.name
        if job == "police" then return "141"
        elseif job == "ambulance" or job == "ems" then return "200"
        elseif job == "firefighter" then return "300"
        end
    end
    return "0"
end

-- Display name: callsign → lastname → FiveM name
Config.getPlayerName = function(serverId)
    if not serverId or serverId <= 0 then return "DISPATCH" end
    local player = exports.qbx_core:GetPlayer(serverId)
    if player and player.PlayerData then
        if player.PlayerData.metadata and player.PlayerData.metadata.callsign
           and player.PlayerData.metadata.callsign ~= "NO CALLSIGN"
           and player.PlayerData.metadata.callsign ~= "" then
            return player.PlayerData.metadata.callsign
        end
        if player.PlayerData.charinfo and player.PlayerData.charinfo.lastname
           and player.PlayerData.charinfo.lastname ~= "" then
            return player.PlayerData.charinfo.lastname
        end
    end
    return GetPlayerName(serverId)
end

-- Make radio useable from inventory (optional, add to bottom of config.lua)
if IsDuplicityVersion() then
    exports.qbx_core:CreateUseableItem('radio', function(source)
        TriggerClientEvent('radio:use', source)
    end)
else
    RegisterNetEvent('radio:use', function()
        exports['radio']:openRadio()
    end)
end

General Settings

config.lua
Config = {
    checkForUpdates = true,   -- Check for script updates on resource start
    logLevel = 3,             -- 0 = Error, 1 = Warn, 2 = Minimal, 3 = Normal, 4 = Debug, 5 = Verbose
    panicTimeout = 60000,     -- Milliseconds before a panic alert auto-clears
}

Talk Check Callback

The talkCheck callback runs on the client before each PTT transmission. Return false to block the transmission.

config.lua
talkCheck = function()
    if IsPlayerDead(PlayerId()) then
        return false
    end

    if IsPedSwimming(PlayerPedId()) then
        return false
    end

    -- Add custom conditions here, e.g.:
    -- if exports['your-handcuff-resource']:IsPlayerCuffed() then
    --     return false
    -- end

    return true
end,

GPS & Signal Settings

config.lua
Config = {
    -- How often GPS blips update (ms). Lower = smoother, higher CPU usage.
    -- Recommended: 50 (smooth), 100 (balanced), 250 (performance), 500 (low-end)
    gpsBlipUpdateRate = 50,

    -- Signal tower positions for the signal-strength icon on the radio.
    -- Cosmetic only — does NOT affect voice quality.
    signalTowerCoordinates = {
        { x = 1860.0,  y = 3677.0,  z = 33.0 },
        { x = 449.0,   y = -992.0,  z = 30.0 },
        { x = -979.0,  y = -2632.0, z = 23.0 },
        { x = -2364.0, y = 3229.0,  z = 45.0 },
        { x = -449.0,  y = 6025.0,  z = 35.0 },
        { x = 1529.0,  y = 820.0,   z = 79.0 },
        { x = -573.0,  y = -146.0,  z = 38.0 },
        { x = -3123.0, y = 1334.0,  z = 25.0 },
        { x = 5266.79, y = -5427.7, z = 139.7 },
    },
}

Add, remove, or move coordinates to match your server's map. The radio icon shows more bars when the player is closer to a tower.


Zones & Channels

Zones group channels together. Players see zones based on their NAC ID matching the zone's nacIds list.

Zone Structure

config.lua
Config.zones = {
    [1] = {
        name = "Statewide",
        nacIds = { "141", "200" },  -- Roles that can see this zone
        Channels = {
            [1] = {
                name = "DISPATCH",
                type = "conventional",
                frequency = 154.755,
                allowedNacs = { "141" },           -- Can connect and scan
                scanAllowedNacs = { "200" },       -- Can scan only
                gps = {
                    color = 54,                    -- Blip color ID
                    visibleToNacs = { "141" }      -- Who sees GPS blips
                }
            }
        }
    }
}

Channel Types

Conventional

Single shared frequency — all connected players hear each other globally.

Conventional Channel
{
    name = "DISPATCH",
    type = "conventional",
    frequency = 154.755,
    allowedNacs = { "141" },
    scanAllowedNacs = { "200" },
    gps = { color = 54, visibleToNacs = { "141" } }
}

Trunked

Location-based frequency assignment within a range. Units at different locations get separate sub-frequencies.

Trunked Channel
{
    name = "CAR-TO-CAR",
    type = "trunked",
    frequency = 856.1125,                  -- Control frequency
    frequencyRange = { 856.000, 859.000 }, -- Available range
    coverage = 500,                        -- Radius in meters
    allowedNacs = { "141" },
    gps = { color = 25, visibleToNacs = { "141" } }
}

Channel Field Reference

FieldTypeDescription
namestringDisplay name on the radio
typestring"conventional" or "trunked"
frequencynumberFrequency in MHz (must be unique across all zones)
frequencyRangetableTrunked only — { min, max } range
coveragenumberTrunked only — radius in meters
allowedNacstableNAC IDs that can connect and scan
scanAllowedNacstableNAC IDs that can scan only (not connect)
gps.colornumberBlip color ID
gps.visibleToNacstableNAC IDs that can see this channel's GPS blips

Custom Alerts

config.lua
alerts = {
    [1] = {
        name = "SIGNAL 100",
        color = "#d19d00",
        isPersistent = true,     -- Stays until manually cleared
        tone = "ALERT_A",        -- Tone name from tones.json
    },
    [2] = {
        name = "Ping",
        color = "#0049d1",
        tone = "ALERT_B",
    },
    [3] = {
        name = "Boop",
        color = "#1c4ba3",
        toneOnly = true,         -- Plays tone without showing an alert
        tone = "BONK",
    },
},
  • The first alert ([1]) is triggered by the in-game SGN button
  • Players with dispatchNacId can trigger the SGN alert from their radio
  • isPersistent keeps the alert active until manually cleared
  • toneOnly plays audio without a visual notification

Custom Tones

Edit client/radios/default/tones.json:

tones.json
{
  "BEEP": [{ "freq": 910, "duration": 250 }],
  "BONK": [{ "freq": 700, "duration": 300 }],
  "ALERT_A": [
    { "freq": 600, "duration": 150, "delay": 200 },
    { "freq": 600, "duration": 150 }
  ]
}
PropertyDescription
freqFrequency in Hz (20–20000)
durationLength in milliseconds
delayOptional pause before next tone (ms)

Per-radio overrides: Place a tones.json in client/radios/YOUR-MODEL/ to override the defaults for that radio model.


Audio Settings

config.lua
Config = {
    voiceVolume = 65,                    -- Default voice volume (0–100)
    sfxVolume = 35,                      -- Default SFX volume (0–100)
    volumeStep = 5,                      -- Volume hotkey increment (1–20)
    playTransmissionEffects = true,      -- Background sounds during transmissions
    analogTransmissionEffects = true,    -- Analog static effects

    radioFx = {
        enabled = true,                  -- false = clean/unprocessed voice
        intensity = 100,                 -- Overall intensity (0–100)
        -- Advanced overrides (uncomment to fine-tune independently):
        -- highpassFrequency = 300,      -- Hz (80–1000)
        -- lowpassFrequency = 3000,      -- Hz (1200–12000)
        -- distortion = 40,             -- (0–100)
        -- compression = 100,           -- (0–100)
        -- midBoost = 3,               -- dB (-12 to 12)
        -- inputGain = 1.65,           -- (0.5–3.0)
    },

    -- 3D Audio (Experimental)
    enable3DAudio = true,                -- Master switch
    default3DAudio = true,               -- true = earbuds OFF (3D on), false = earbuds ON (3D off)
    default3DVolume = 50,                -- Default 3D volume (0–100)
    vehicle3DActivationDistance = 1.0,   -- Min distance (meters) before vehicle 3D audio activates

    -- PTT
    pttReleaseDelay = 350,               -- Delay before releasing PTT (ms), 250–500 recommended
    pttTriggersProximity = true,         -- PTT also triggers proximity voice chat
}

Interference

config.lua
Config = {
    bonkingEnabled = true,               -- Enable radio interference
    bonkInterval = 750,                  -- Interference interval (ms)
    interferenceTimeout = 5000,          -- Duration of interference (ms)
    blockAudioDuringInterference = true, -- Block voice during interference
}

Note

These settings are reserved for future expansion. The current interference system uses simplified detection and may not reference all values.


Background Siren Detection

The bgSirenCheck callback detects whether the player's vehicle siren audio is active, used to play siren background effects during radio transmissions.

With Luxart Vehicle Control (Default)

Required LVC Version

The siren integration requires Luxart Vehicle Control v3.2.9 Rev 2 or newer. Rev 2 added the lvc:UpdateThirdParty event that Tommy's Radio uses to track siren state.

Download: Luxart Vehicle Control v3.2.9-Rev2 from the releases page.

Earlier versions of LVC do not emit this event and the siren detection will not work.

config.lua — LVC version (default)
bgSirenCheck = function(lvcSirenState)
    local playerPed = PlayerPedId()
    if not playerPed or playerPed == 0 then return false end

    local vehicle = GetVehiclePedIsIn(playerPed, false)
    if not vehicle or vehicle == 0 then return false end

    -- lvcSirenState: 0 = no siren audio (lights only or off), >0 = siren audio playing
    return lvcSirenState and lvcSirenState > 0
end,

The lvcSirenState parameter is tracked automatically via the lvc:UpdateThirdParty event. A value of 0 means lights-only or off; any value greater than 0 means siren audio is active (Wail, Yelp, Priority, etc.).

Without LVC (Standalone)

If you don't use Luxart Vehicle Control, replace the function with a native fallback:

config.lua — Non-LVC fallback
bgSirenCheck = function(lvcSirenState)
    local playerPed = PlayerPedId()
    if not playerPed or playerPed == 0 then return false end

    local vehicle = GetVehiclePedIsIn(playerPed, false)
    if not vehicle or vehicle == 0 then return false end

    if not IsVehicleSirenOn(vehicle) then return false end

    local speed = GetEntitySpeed(vehicle) * 2.237 -- m/s → mph
    if speed <= 10 then return false end

    return true
end,

Limitation

The native IsVehicleSirenOn() cannot distinguish lights-only from siren audio. This fallback may trigger false positives when only emergency lights are on.


Player Animations

Separate File

As of v3.7, animations are defined in animations.lua (loaded alongside config.lua). This keeps the main config clean.

Each animation entry has:

  • name — display name in the settings menu
  • onKeyState(isKeyDown) — called when PTT key is pressed (true) or released (false)
  • onRadioFocus(focused) — called when the radio UI is focused (true) or unfocused (false)

Built-in Animations

IndexNamePTT BehaviorFocus Behavior
1NoneNo animationNo animation
2ShoulderShoulder-radio gestureHandheld with prop
3HandheldHandheld with propHandheld with prop
4EarpieceEar-touch gestureNo animation

animations.lua includes helper functions (MakePTTHandler, MakeFocusHandler) for building standard animation entries. Index [1] is the fallback if a player's saved animation no longer exists.

Adding Custom Animations with RPEmotes

You can add custom radio animations using the rpemotes resource. This example uses custom LEO animation poses.

Step 1: Download Custom Animation Files

  1. Download the LEO Custom Anim pack from GTA5-Mods
  2. Create a folder (e.g., radio) inside rpemotes/stream/
  3. Place all downloaded .ycd animation files into this folder

These files provide the custom animation dictionaries (like anim@cop_mic_pose_002, anim@radio_left, anim@male@holding_radio) used by some of the emotes below.

Step 2: Add Walkie Talkie Emotes to RPEmotes

The radio configuration references emotes like wt, wt4, radiochest, etc. These must exist in rpemotes for the integration to work.

Check rpemotes/client/AnimationList.lua for the following walkie talkie entries. If they are missing, add them inside the DP.Emotes table:

rpemotes/client/AnimationList.lua — Walkie Talkie Emotes
["wt"] = {
    "cellphone@",
    "cellphone_text_read_base",
    "Walkie Talkie",
    AnimationOptions = {
        Prop = "prop_cs_hand_radio",
        PropBone = 28422,
        PropPlacement = { 0.0, 0.0, 0.0, 0.0, 0.0, 0.0 },
        onFootFlag = AnimFlag.MOVING,
    }
},
["wt2"] = {
    "anim@radio_pose_3",
    "radio_holding_gun",
    "Walkie Talkie 2",
    AnimationOptions = {
        Prop = "prop_cs_hand_radio",
        PropBone = 60309,
        PropPlacement = { 0.0750, 0.0470, 0.0110, -97.9442, 3.7058, -23.2367 },
        onFootFlag = AnimFlag.LOOP,
    }
},
["wt3"] = {
    "anim@radio_left",
    "radio_left_clip",
    "Walkie Talkie 3 Left",
    AnimationOptions = {
        Prop = "prop_cs_hand_radio",
        PropBone = 60309,
        PropPlacement = { 0.0750, 0.0470, 0.0110, -97.9442, 3.7058, -23.2367 },
        onFootFlag = AnimFlag.MOVING,
    }
},
["wt4"] = {
    "anim@male@holding_radio",
    "holding_radio_clip",
    "Walkie Talkie 4",
    AnimationOptions = {
        Prop = "prop_cs_hand_radio",
        PropBone = 28422,
        PropPlacement = { 0.0750, 0.0230, -0.0230, -90.0000, 0.0000, -59.9999 },
        onFootFlag = AnimFlag.MOVING,
    }
},
["wt5"] = {
    "missfbi3_steve_phone",
    "steve_phone_idle_a",
    "Walkie Talkie 5",
    AnimationOptions = {
        Prop = "prop_cs_hand_radio",
        PropBone = 18905,
        PropPlacement = { 0.1300, 0.0500, 0.0100, -113.0000, 0.0000, -60.0000 },
        onFootFlag = AnimFlag.MOVING,
    }
},

Note

The wt and wt5 emotes use vanilla GTA animation dictionaries. The wt2, wt3, and wt4 emotes use custom dictionaries from the LEO animation pack downloaded in Step 1.

Step 3: Add Custom Emotes to AnimationListCustom

Add the following inside the CustomDP.Emotes = {} table in rpemotes/client/AnimationListCustom.lua:

rpemotes/client/AnimationListCustom.lua
["radio2"] = {
    "random@arrests",
    "radio_chatter",
    "Radio 2",
    AnimationOptions = {
        EmoteLoop = true,
        EmoteMoving = true,
    }
},
["radiochest"] = {
    "anim@cop_mic_pose_002",
    "chest_mic",
    "Radio Chest",
    AnimationOptions = {
        EmoteLoop = true,
        EmoteMoving = true,
    }
},
["earpiece"] = {
    "cellphone@",
    "cellphone_call_listen_base",
    "Earpiece",
    AnimationOptions = {
        EmoteLoop = true,
        EmoteMoving = true,
    }
},

After adding these entries, restart your server or refresh and restart the rpemotes resource.

Step 4: Configure in animations.lua

Uncomment or add entries in animations.lua that use these emotes:

animations.lua
[5] = {
    name = "Chest",
    onKeyState = function(isKeyDown)
        if isKeyDown then
            exports["rpemotes"]:EmoteCommandStart("radiochest", 0)
        else
            exports["rpemotes"]:EmoteCancel(true)
        end
    end,
    onRadioFocus = function(focused)
        if focused then
            exports["rpemotes"]:EmoteCommandStart("wt", 0)
        else
            exports["rpemotes"]:EmoteCancel(true)
        end
    end,
},

[6] = {
    name = "Handheld2",
    onKeyState = function(isKeyDown)
        if isKeyDown then
            exports["rpemotes"]:EmoteCommandStart("wt4", 0)
        else
            exports["rpemotes"]:EmoteCancel(true)
        end
    end,
    onRadioFocus = function(focused)
        if focused then
            exports["rpemotes"]:EmoteCommandStart("wt", 0)
        else
            exports["rpemotes"]:EmoteCancel(true)
        end
    end,
},

RPEmotes Integration Notes

  • Replace "rpemotes" in exports["rpemotes"] with your actual resource name if different
  • EmoteCommandStart(emoteName, 0) starts the emote; EmoteCancel(true) stops it
  • You can mix and match any registered emote for onKeyState and onRadioFocus
  • Available emotes: wtwt5, radio2, radiochest, earpiece (and any others you register)

Battery System

config.lua
batteryTick = function(currentBattery, deltaTime)
    local playerPed = PlayerPedId()
    local vehicle = GetVehiclePedIsIn(playerPed, false)

    if vehicle ~= 0 then
        local chargeRate = 0.5 -- % per second
        return math.min(100.0, currentBattery + (chargeRate * deltaTime))
    else
        local dischargeRate = 0.1 -- % per second
        return math.max(0.0, currentBattery - (dischargeRate * deltaTime))
    end
end,

Called every second. Return the new battery level (0–100). The default charges in vehicles and drains on foot. Customize rates or add conditions as needed.


Callsign System

config.lua
Config = {
    useCallsignSystem = true,
    callsignCommand = "callsign",  -- /callsign 2L-319 (set to "" to disable command)
}

When enabled, players can set a custom callsign via the in-game command or dispatch panel. Callsigns persist in client KVP storage across sessions.

  • Enabled: getPlayerName is the default/fallback. Custom callsigns take priority once set.
  • Disabled: getPlayerName is the only source for display names.

Radio Layouts

Available Layouts

config.lua
Config.radioLayouts = {
    "AFX-1500",      -- Mobile
    "AFX-1500G",     -- Marine mobile
    "ARX-4000X",     -- Compact handheld
    "XPR-6500",      -- Professional mobile
    "XPR-6500S",     -- Slim mobile
    "ATX-8000",      -- Advanced handheld
    "ATX-8000G",     -- Government handheld
    "ATX-NOVA",      -- Futuristic handheld
    "TXDF-9100",     -- Aviation
}

Auto Layout Assignment

config.lua
defaultLayouts = {
    -- Vehicle type defaults (all four required)
    ["Handheld"] = "ATX-8000",
    ["Vehicle"]  = "AFX-1500",
    ["Boat"]     = "AFX-1500G",
    ["Air"]      = "TXDF-9100",

    -- Per-vehicle overrides by spawn code
    ["fbi2"]   = "XPR-6500",
    ["police"] = "XPR-6500",
}

Vehicle-specific overrides (by spawn code) take priority over the type defaults.

Creating Custom Layouts

  1. Copy an existing layout folder from client/radios/
  2. Rename the folder to your custom layout name
  3. Replace assets:
    • radio.png — main radio image
    • radio-dark.png — dark mode variant
    • icons/ — button icons
  4. Update config.json with button/display positioning
  5. Add the name to Config.radioLayouts

Developer Tools

Radio Debug Mode

Command: /radio_debug (radio must be open)

Shows visual overlays with color-coded borders:

  • Red = Buttons | Blue = Displays | Green = Icons | Orange = LEDs

Click anywhere to log coordinates (red dot appears). Format: Click coordinates: x=123, y=456. Use top-left and bottom-right coordinates to calculate element dimensions. All coordinates are relative to the radio PNG top-left (0,0).

config.json element example
{
  "key": "power",
  "x": 180, "y": 161,
  "width": 25, "height": 25
}

Radio Sound Test

Command: /rplay <sound_type> (radio must be on)

/rplay trans        # Full transmission simulation
/rplay gunshot      # Background gunshot
/rplay siren        # Background siren (3s)
/rplay heli         # Background helicopter (3s)
/rplay BEEP         # Any tone name from tones.json
/rplay ALERT_A

Workflow: Edit tones.jsonbun run build → restart resource → /rplay YOUR_TONE

Reset Radio Position

Command: /resetRadio

Resets scale and position to defaults. Use if the radio is dragged off-screen or has positioning issues.


Maintenance

Files to Back Up

  • config.lua
  • animations.lua
  • Custom radio layouts in client/radios/

Updating

  1. Note the config version at the top of your current config.lua (e.g., Config Version 3.7)
  2. Back up your configuration files
  3. Extract the new resource files
  4. Compare config versions:
    • Same version → keep your existing config
    • Different version → use diffchecker.com to compare and merge changes
  5. Restore your custom settings (authToken, dispatchNacId, zones, callbacks, etc.)
  6. Test before going live

Security Reminder

Always change authToken and dispatchNacId from defaults before production use.

On this page