Setup & Configuration
Complete installation and configuration guide for Tommy's Radio system.
Prerequisites
- FiveM server
- Access to firewall or hosting panel
- An available port for the voice server (default:
7777)
Installation
1. Extract Files
- Download the radio resource
- Extract to your server's
resourcesfolder - 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 = {
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:
- FiveM (usually 30120) — game server
- txAdmin (usually 40120) — management panel
- 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)
- In your hosting panel, find Network, Ports, or Allocations
- Add a new port allocation — the panel will assign a number (e.g.,
50978) - Update
config.lua:
Config = {
serverPort = 50978,
serverAddress = "http://YOUR_SERVER_IP:50978/",
}- Restart your entire server from the panel (not just the resource)
- 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 7777CentOS/RHEL:
sudo firewall-cmd --permanent --add-port=7777/tcp
sudo firewall-cmd --reloadWindows Server:
netsh advfirewall firewall add rule name="Radio Voice Server" dir=in action=allow protocol=TCP localport=7777Config = {
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 ports —
serverPortandserverAddressmust use the same port - Testing before resource starts — the port only opens when the radio resource is running
- Wrong
serverAddressformat — must be"http://IP:PORT/"withhttp://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:
start radio5. Setup Dispatch Panel
Access your panel at http://yourserverip:yourport/ after the resource starts.
Desktop App (Recommended)
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.
- Open app and login with
141(demo default) - Click the settings cog (⚙️) at top-right
- Set Endpoint URL to
http://YOUR_IP:YOUR_PORT/ - Save — app refreshes
- 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-securefor 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.
- Set
useDiscordAuth = trueinconfig.lua - Create
server/.env(copy fromserver/.env.example):
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- Get credentials from the Discord Developer Portal
- 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:
-- 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)
endWhen 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:
['radio'] = {
['name'] = 'radio',
['label'] = 'Radio',
['weight'] = 1000,
['type'] = 'item',
['image'] = 'radio.png',
['unique'] = false,
['useable'] = true,
['shouldClose'] = true,
['combinable'] = nil,
['description'] = 'Handheld radio transceiver'
},-- 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)
endESX
Radio Item (Optional)
Add to your database if you want to require a radio item:
INSERT INTO `items` (`name`, `label`, `weight`, `rare`, `can_remove`) VALUES
('radio', 'Radio', 1, 0, 1);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)
endQbox (QBX Core)
Radio Item (Optional)
Add to ox_inventory/data/items.lua if you want to require a radio item:
['radio'] = {
label = 'Radio',
weight = 500,
stack = false,
close = true,
client = {
event = 'radio:use',
},
},-- 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)
endGeneral Settings
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.
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 = {
-- 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.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.
{
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.
{
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
| Field | Type | Description |
|---|---|---|
name | string | Display name on the radio |
type | string | "conventional" or "trunked" |
frequency | number | Frequency in MHz (must be unique across all zones) |
frequencyRange | table | Trunked only — { min, max } range |
coverage | number | Trunked only — radius in meters |
allowedNacs | table | NAC IDs that can connect and scan |
scanAllowedNacs | table | NAC IDs that can scan only (not connect) |
gps.color | number | Blip color ID |
gps.visibleToNacs | table | NAC IDs that can see this channel's GPS blips |
Custom Alerts
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
dispatchNacIdcan trigger the SGN alert from their radio isPersistentkeeps the alert active until manually clearedtoneOnlyplays audio without a visual notification
Custom Tones
Edit client/radios/default/tones.json:
{
"BEEP": [{ "freq": 910, "duration": 250 }],
"BONK": [{ "freq": 700, "duration": 300 }],
"ALERT_A": [
{ "freq": 600, "duration": 150, "delay": 200 },
{ "freq": 600, "duration": 150 }
]
}| Property | Description |
|---|---|
freq | Frequency in Hz (20–20000) |
duration | Length in milliseconds |
delay | Optional 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 = {
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 = {
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.
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:
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 menuonKeyState(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
| Index | Name | PTT Behavior | Focus Behavior |
|---|---|---|---|
| 1 | None | No animation | No animation |
| 2 | Shoulder | Shoulder-radio gesture | Handheld with prop |
| 3 | Handheld | Handheld with prop | Handheld with prop |
| 4 | Earpiece | Ear-touch gesture | No 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
- Download the LEO Custom Anim pack from GTA5-Mods
- Create a folder (e.g.,
radio) insiderpemotes/stream/ - Place all downloaded
.ycdanimation 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:
["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:
["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:
[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"inexports["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
onKeyStateandonRadioFocus - Available emotes:
wt–wt5,radio2,radiochest,earpiece(and any others you register)
Battery System
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 = {
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:
getPlayerNameis the default/fallback. Custom callsigns take priority once set. - Disabled:
getPlayerNameis the only source for display names.
Radio Layouts
Available Layouts
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
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
- Copy an existing layout folder from
client/radios/ - Rename the folder to your custom layout name
- Replace assets:
radio.png— main radio imageradio-dark.png— dark mode varianticons/— button icons
- Update
config.jsonwith button/display positioning - 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).
{
"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_AWorkflow: Edit tones.json → bun 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.luaanimations.lua- Custom radio layouts in
client/radios/
Updating
- Note the config version at the top of your current
config.lua(e.g.,Config Version 3.7) - Back up your configuration files
- Extract the new resource files
- Compare config versions:
- Same version → keep your existing config
- Different version → use diffchecker.com to compare and merge changes
- Restore your custom settings (authToken, dispatchNacId, zones, callbacks, etc.)
- Test before going live
Security Reminder
Always change authToken and dispatchNacId from defaults before production use.