Koja Scripts

API examples

Complete koja-dispatcher integration recipes: phone 911, robbery alerts, ALPR cameras, priority hooks, shift bans, Discord logs, unit status, live maps, BOLOs, escalating scenarios and custom admin settings.

API examples

Complete recipes you can drop into your own resource. Each one assumes koja-dispatcher is started before your resource (see the overview).

RecipeUses
Phone app 911PhoneCall, CreateCall, Call911
Store robbery alert with auto-assignCreateIncident, CreateCall, GetDispatcherCount
ALPR camera scriptReportPlate
Priority 1 for Fleeca robberiesincidentCreate hook
Block shifts for suspended playersshiftStart hook
Discord or log on incidentEndedkoja-dispatcher:incidentEnded, DiscordLog
Field unit status from a job scriptRunFieldCode, SetUnitStatus, GetPlayerUnit
Units on a custom mapGetUnits, unitAdded / unitRemoved
Create a BOLO from another scriptCreateBolo, CloseBolo, GetBolos
Escalation in a custom scenarioshared/scenarios/*.lua, events, stages
Register your own admin settingRegisterSetting, GetSetting, settingChanged

Phone app 911

Your phone's 911 app sends the text to its own server event, which hands it to the dispatcher with the player as the caller.

server.lua (PhoneCall)
-- PhoneCall works with any phone: it uses the /911 logic, so follow-up texts
-- within calls.playerReplyWindow join the open call. Needs phones.enabled.
RegisterNetEvent('myphone:server:call911', function(text)
    local src = source
    if type(text) ~= 'string' then return end

    local id, route = exports['koja-dispatcher']:PhoneCall(src, text)
    if not id then
        if route == 'rate' then
            TriggerClientEvent('myphone:client:notify', src, 'Please wait before calling again.')
        end
        -- 'no_dispatchers': the player already got a "nobody is available" notification
        return
    end
    if route == 'incident' then
        TriggerClientEvent('myphone:client:notify', src, 'Units have been sent to your location.')
    elseif route == 'call' then
        TriggerClientEvent('myphone:client:notify', src, 'Connecting you to the dispatcher...')
    end
end)
server.lua (CreateCall)
-- CreateCall gives you full control (caller name, category) but every message
-- is a new call. Works even with phones.enabled off.
local lastCall = {}

RegisterNetEvent('myphone:server:call911', function(text)
    local src = source
    if type(text) ~= 'string' then return end
    text = text:gsub('%c', ' '):sub(1, 250)
    if #text < 3 then return end

    local now = os.time()
    if lastCall[src] and now - lastCall[src] < 30 then return end
    lastCall[src] = now

    local id, route = exports['koja-dispatcher']:CreateCall({
        source = src,                -- position, notifications and replies go to this player
        caller = GetPlayerName(src),
        message = text,
    })
    if not id then
        TriggerClientEvent('myphone:client:notify', src, route == 'no_dispatchers'
            and 'Nobody is answering 911 right now.' or 'The call could not be connected.')
    end
end)

AddEventHandler('playerDropped', function() lastCall[source] = nil end)
client.lua (Call911)
-- Call911 is the /911 command, called from your phone UI
RegisterNUICallback('call911', function(data, cb)
    local ok, route = exports['koja-dispatcher']:Call911(tostring(data.text or ''))
    cb({ ok = ok, route = route })
end)

Store robbery alert with auto-assign

The robbery script tells its server side that the alarm went off. The server checks the player is really at the store, then creates the incident and sends the nearest free units.

server.lua
local stores = {
    ltd_grove = { label = 'LTD Gasoline, Grove Street', coords = vector3(-47.5, -1757.6, 29.4) },
    twentyfour_innocence = { label = '24/7, Innocence Blvd', coords = vector3(25.7, -1347.3, 29.5) },
}
local lastAlarm = {}

RegisterNetEvent('myrobbery:server:alarm', function(storeId)
    local src = source
    local store = stores[storeId]
    if not store then return end

    -- never trust the client: the robber must stand in the store
    if #(GetEntityCoords(GetPlayerPed(src)) - store.coords) > 30.0 then return end

    -- one alarm per store every 10 minutes
    local now = os.time()
    if lastAlarm[storeId] and now - lastAlarm[storeId] < 600 then return end
    lastAlarm[storeId] = now

    local incidentId, err = exports['koja-dispatcher']:CreateIncident({
        category = 'robbery',
        priority = 1,
        coords = store.coords,
        label = store.label,
        need = { police = 2 },
        note = 'Silent alarm, armed robbery in progress',
        autoAssign = true,           -- nearest free units for { police = 2 }
    })
    if not incidentId then
        print(('[myrobbery] alarm failed: %s'):format(err))
    end
end)
server.lua (ring a dispatcher first)
-- With a dispatcher on shift the alarm rings at the console and the dispatcher
-- decides; otherwise the incident is created and auto-assigned.
local function storeAlarm(store)
    local dispatch = exports['koja-dispatcher']
    if dispatch:GetDispatcherCount() > 0 then
        return dispatch:CreateCall({
            coords = store.coords,
            label = store.label,
            caller = 'Alarm company',
            message = ('Silent alarm at %s'):format(store.label),
            category = 'robbery',
            priority = 1,
            need = { police = 2 },
        })
    end
    return dispatch:CreateIncident({
        category = 'robbery', priority = 1, coords = store.coords, label = store.label,
        need = { police = 2 }, note = 'Silent alarm', autoAssign = true,
    })
end
client.lua
-- in your robbery script, when the register is opened
TriggerServerEvent('myrobbery:server:alarm', 'ltd_grove')

ALPR camera script

Fixed cameras on the server read every vehicle that passes and report the plate. ReportPlate only alerts the dispatchers when the plate has an active BOLO, so you can report every read.

server.lua
local cameras = {
    { label = 'Camera 14, Del Perro Fwy', coords = vector3(-1236.0, -722.0, 21.0), radius = 25.0 },
    { label = 'Camera 3, Route 68', coords = vector3(1172.0, 2696.0, 37.9), radius = 25.0 },
}
local lastRead = {}

CreateThread(function()
    while true do
        Wait(1000)
        local now = os.time()
        for _, vehicle in ipairs(GetAllVehicles()) do
            local pos = GetEntityCoords(vehicle)
            for index, camera in ipairs(cameras) do
                if #(pos - camera.coords) <= camera.radius then
                    local plate = GetVehicleNumberPlateText(vehicle)
                    local key = index .. ':' .. tostring(plate)
                    if plate and (not lastRead[key] or now - lastRead[key] > 60) then
                        lastRead[key] = now
                        local hits = exports['koja-dispatcher']:ReportPlate(plate, pos, camera.label)
                        if #hits > 0 then
                            print(('[alpr] %s hit BOLO %s (%s)'):format(plate, hits[1].id, hits[1].title))
                        end
                    end
                end
            end
        end
    end
end)

A hit puts an ALPR alert and a radio line in every dispatcher's CAD and moves the BOLO's last-seen point to the camera. It needs mdt_tools.alpr on.


Priority 1 for Fleeca robberies

An incidentCreate hook upgrades every robbery near a Fleeca branch, whether it came from a dispatcher, a robbery script, a phone or a dispatch-script alert.

server.lua
local fleeca = {
    vector3(149.9, -1040.7, 29.4),    -- Legion Square
    vector3(314.2, -278.6, 54.2),     -- Hawick Ave
    vector3(-351.5, -49.5, 49.0),     -- Burton
    vector3(-1212.9, -330.8, 37.8),   -- Rockford Hills
    vector3(-2962.6, 482.6, 15.7),    -- Great Ocean Hwy
    vector3(1175.0, 2706.6, 38.1),    -- Route 68
}

local function nearFleeca(coords)
    if not coords then return false end
    local point = vector3(coords.x, coords.y, coords.z or 0.0)
    for _, branch in ipairs(fleeca) do
        if #(point.xy - branch.xy) < 60.0 then return true end
    end
    return false
end

exports['koja-dispatcher']:RegisterHook('incidentCreate', function(data)
    if data.training or data.category ~= 'robbery' then return end
    local byLabel = type(data.label) == 'string' and data.label:lower():find('fleeca') ~= nil
    if byLabel or nearFleeca(data.real or data.coords) then
        return {
            priority = 1,
            need = { police = 3 },
            known = { police = 3 },
            note = 'Fleeca bank robbery: priority 1, three units minimum',
        }
    end
end)

data.real holds the true position when the dispatcher dispatched a call before learning the exact location.


Block shifts for suspended players

A shiftStart hook refuses the shift with a reason. The CAD shows the locale text errors.<reason>, so add a suspended entry under game.errors in the locale files (or return an existing key such as not_dispatcher). Suspensions are kept in the resource KVP so they survive restarts.

server.lua
local function licenseOf(src)
    return GetPlayerIdentifierByType(src, 'license')
end

local function suspendedUntil(license)
    return GetResourceKvpInt(('suspended:%s'):format(license))
end

exports['koja-dispatcher']:RegisterHook('shiftStart', function(data)
    local license = licenseOf(data.source)
    if license and suspendedUntil(license) > os.time() then
        return false, 'suspended'
    end
end)

-- /dispatchsuspend <id> <hours>, restricted with: add_ace group.admin command.dispatchsuspend allow
RegisterCommand('dispatchsuspend', function(src, args)
    local target, hours = tonumber(args[1]), tonumber(args[2])
    local license = target and licenseOf(target)
    if not license or not hours then return end
    SetResourceKvpInt(('suspended:%s'):format(license), os.time() + math.floor(hours * 3600))
    print(('suspended %s from dispatch for %sh'):format(GetPlayerName(target), hours))
end, true)
koja-dispatcher/locales/en.json (game.errors)
"suspended": "You are suspended from dispatch duty."

The hook only stops new shifts. To remove a dispatcher who is already on shift, end it in your own code (for example by kicking them from the job in your framework).


Discord or log on incidentEnded

Dispatcher webhook
-- uses the dispatcher's own webhooks (convar koja_dispatcher_webhook_incidents)
AddEventHandler('koja-dispatcher:incidentEnded', function(incident)
    if incident.status ~= 'resolved' or incident.priority ~= 1 then return end
    exports['koja-dispatcher']:DiscordLog('incidents',
        ('Priority 1 #%d closed'):format(incident.id),
        incident.note,
        {
            { name = 'Category', value = incident.category },
            { name = 'Outcome', value = incident.outcome or 'resolved' },
            { name = 'Location', value = incident.label or ('%.0f, %.0f'):format(incident.x, incident.y), inline = false },
            { name = 'Duration', value = ('%ds'):format((incident.endedAt or os.time()) - incident.createdAt) },
            { name = 'Dispatchers on shift', value = tostring(exports['koja-dispatcher']:GetDispatcherCount()) },
        },
        0x57F287)
end)
Own webhook
local webhook = GetConvar('my_incident_webhook', '')

AddEventHandler('koja-dispatcher:incidentEnded', function(incident)
    if webhook == '' then return end
    local body = json.encode({
        username = 'Incident log',
        embeds = { {
            title = ('Incident #%d %s'):format(incident.id, incident.status),
            description = ('%s, priority %d, outcome %s'):format(incident.category, incident.priority, tostring(incident.outcome)),
            color = incident.status == 'resolved' and 5763719 or 15548997,
        } },
    })
    PerformHttpRequest(webhook, function() end, 'POST', body, { ['Content-Type'] = 'application/json' })
end)
Database log
AddEventHandler('koja-dispatcher:incidentEnded', function(incident)
    MySQL.insert('INSERT INTO my_incident_log (incident, category, status, outcome, origin, ended_at) VALUES (?, ?, ?, ?, ?, ?)', {
        incident.id, incident.category, incident.status, incident.outcome, incident.origin, incident.endedAt,
    })
end)

The dispatcher already stores every closed incident in koja_dispatch_history when stats.enabled is on; use your own table only for extra data.


Field unit status from a job script

A booking or evidence script marks the officer busy while they work and available afterwards. RunFieldCode behaves like the officer's own key (radio line, HUD chip, koja-mdt sync); SetUnitStatus is the plain status change.

RunFieldCode
-- server.lua of your booking script
RegisterNetEvent('mybooking:server:start', function()
    local src = source
    local ok, err = exports['koja-dispatcher']:RunFieldCode(src, '10-6')   -- busy
    if not ok and err ~= 'not_field_unit' then print('status failed: ' .. err) end
end)

RegisterNetEvent('mybooking:server:finish', function()
    exports['koja-dispatcher']:RunFieldCode(source, '10-8')                -- available
end)
SetUnitStatus
RegisterNetEvent('mybooking:server:start', function()
    local src = source
    local unit = exports['koja-dispatcher']:GetPlayerUnit(src)
    if not unit then return end                 -- not a field unit right now
    if unit.incident then return end            -- keep officers that are on a call
    exports['koja-dispatcher']:SetUnitStatus(src, 'busy')
end)

RegisterNetEvent('mybooking:server:finish', function()
    exports['koja-dispatcher']:SetUnitStatus(source, 'available')
end)

Units on a custom map

The server sends a compact unit list every 2 seconds to the players who use your map (here: an ACE permission), and the client draws blips.

server.lua
local viewers = {}

RegisterNetEvent('mymap:server:open', function()
    local src = source
    if IsPlayerAceAllowed(src, 'mymap.view') then viewers[src] = true end
end)

RegisterNetEvent('mymap:server:close', function() viewers[source] = nil end)
AddEventHandler('playerDropped', function() viewers[source] = nil end)

CreateThread(function()
    while true do
        Wait(2000)
        if next(viewers) then
            local list = {}
            for _, unit in ipairs(exports['koja-dispatcher']:GetUnits()) do
                if unit.status ~= 'offline' then
                    list[#list + 1] = {
                        id = unit.id, callsign = unit.callsign, dept = unit.dept,
                        status = unit.status, x = unit.x, y = unit.y, h = unit.h,
                        incident = unit.incident,
                    }
                end
            end
            for src in pairs(viewers) do TriggerClientEvent('mymap:client:units', src, list) end
        end
    end
end)
client.lua
local blips = {}
local colours = { police = 3, ems = 1, fire = 17 }

RegisterNetEvent('mymap:client:units', function(list)
    local seen = {}
    for _, unit in ipairs(list) do
        seen[unit.id] = true
        local blip = blips[unit.id]
        if not blip then
            blip = AddBlipForCoord(unit.x, unit.y, 0.0)
            SetBlipSprite(blip, unit.dept == 'police' and 56 or 61)
            SetBlipColour(blip, colours[unit.dept] or 0)
            SetBlipScale(blip, 0.8)
            BeginTextCommandSetBlipName('STRING')
            AddTextComponentSubstringPlayerName(unit.callsign)
            EndTextCommandSetBlipName(blip)
            blips[unit.id] = blip
        end
        SetBlipCoords(blip, unit.x, unit.y, 0.0)
        SetBlipRotation(blip, math.floor(unit.h or 0))
        SetBlipFlashes(blip, unit.status == 'enroute')
    end
    for id, blip in pairs(blips) do
        if not seen[id] then RemoveBlip(blip); blips[id] = nil end
    end
end)

For a log of who is on duty, listen to koja-dispatcher:unitAdded and koja-dispatcher:unitRemoved instead of polling.


Create a BOLO from another script

CreateBolo creates the BOLO where the CAD keeps them (mdt_tools.boloSource): in koja-mdt while it runs, otherwise in the dispatcher's own list. Every dispatcher sees it at once, and with koja-mdt officers see it in the MDT too.

server.lua
-- a bank robbery script flags the getaway car
RegisterNetEvent('mybank:server:getaway', function(netId)
    local src = source
    local vehicle = NetworkGetEntityFromNetworkId(netId)
    if vehicle == 0 or #(GetEntityCoords(vehicle) - GetEntityCoords(GetPlayerPed(src))) > 15.0 then return end

    local pos = GetEntityCoords(vehicle)
    local boloId, err = exports['koja-dispatcher']:CreateBolo({
        type = 'vehicle',
        title = 'Getaway car, armed occupants',
        plate = GetVehicleNumberPlateText(vehicle),
        description = 'Fled the Pacific Standard robbery',
        priority = 1,
        point = { x = pos.x, y = pos.y },
        label = 'Pacific Standard Bank',
    }, 'Pacific Standard security')

    if not boloId then
        print('[mybank] BOLO failed: ' .. err)   -- e.g. plate_required, mdt_offline
        return
    end

    -- close it automatically after 30 minutes
    SetTimeout(30 * 60 * 1000, function()
        exports['koja-dispatcher']:CloseBolo(boloId)
    end)
end)
ResultMeaning
'm12'Created in koja-mdt as BOLO 12 (also visible in the MDT).
'i7'Created in the dispatcher's own list; on-duty police units get a notification (mdt_tools.notifyUnits) and koja-dispatcher:boloCreated fires.
nil, 'mdt_offline'mdt_tools.boloSource = 'mdt' and koja-mdt is not running.

GetBolos() returns the list as the CAD sees it about 0.3 s after the change. All fields and errors are on the server exports page.


Escalation in a custom scenario

Generated calls come from Scenarios.List. Add your own in a new file in shared/scenarios/ (loaded by the manifest, not escrowed). This one starts as a burglary, escalates while the caller is on the line, and continues on scene in stages.

koja-dispatcher/shared/scenarios/my_scenarios.lua
local list = {
    jewelry_after_hours = {
        category = 'burglary',
        priority = 2,
        weight = 2,                          -- how often it is picked compared to others
        tags = { 'jewelry', 'store' },       -- location tags from shared/locations.lua
        distress = 0.5,
        time = { 'night' },                  -- more likely at night
        opening = {
            "I'm the night guard at the jewelry store, the back door alarm just went off.",
            "Someone is inside the jewelry store, I can see flashlights through the window!",
        },
        answers = {
            what = 'Back door alarm, then I saw two flashlights moving behind the counters.',
            suspects = { chance = 0.6, text = 'At least three of them, masks on.', reveal = { police = 1 }, alt = 'I only saw one.' },
            weapons = { chance = 0.4, text = 'One of them has a shotgun!', reveal = { police = 1 } },
        },
        base = { police = 1 },
        instruction = 'hide',
        respond = 150,
        fail = 360,
        work = 60,
        points = 140,
        scene = 'crowd',
        escalate = { after = 180, add = { police = 1 }, text = 'Suspects are loading a van out back!' },

        events = {
            -- 20 s after the dispatcher answered: the situation changes on the line
            {
                at = 20,
                line = "They smashed the display cases, they're grabbing everything!",
                radio = 'Caller reports the suspects are smashing display cases.',
                distress = 0.2,
                priority = 1,
                known = { police = 1 },
                alert = 'escalation',
            },
            -- only when the dispatcher asks about weapons, with a 2 s delay
            {
                on = 'ask:weapons',
                chance = 0.5,
                delay = 2,
                line = 'Oh no, one of them just fired at the ceiling!',
                category = 'shooting',               -- coding the call as shooting also counts as correct
                need = { ems = 1 },
                alert = 'shots',
            },
            -- 45 s after dispatch: a callback reaches the incident
            {
                after = 45,
                when = 'incident',
                line = 'A neighbour called back: the van is leaving north on Palomino!',
                radio = 'Callback: suspect van leaving north on Palomino Ave.',
                need = { police = 1 },
                priority = 1,
            },
        },

        stages = {
            { name = 'Burglary in progress', note = 'Suspects inside the store.' },
            {
                name = 'Injured guard',
                category = 'medical',
                need = { ems = 1, police = 1 },
                work = 40,
                fail = 240,
                transport = true,
                points = 40,
                radio = 'Store secured. The night guard was assaulted and needs EMS.',
                note = 'Guard with a head wound behind the counter.',
            },
        },
    },
}

for key, scenario in pairs(list) do
    if Scenarios.List[key] == nil then Scenarios.List[key] = scenario end
end
Event fieldMeaning
atSeconds after the call was answered. If the call was dispatched already, it fires on the incident.
onask:<question>, ask, instruct:<option>, instruct, instruct:good, instruct:bad. Only while the caller is on the line.
afterSeconds after dispatch, on the incident only.
when'call' or 'incident' limits where it may fire.
chanceRolled once per call.
delaySeconds between the trigger and the event (default Config.Dynamics.eventDelay, 2).
lineCaller line (string or list, {location} works).
radio, alert, ai, noteRadio text, CAD alert kind, extra context for AI callers, incident note.
distress, priority, categoryAdded distress, raised priority, category suggestion.
need / knownHidden extra units / extra units the dispatcher learns about.
silentThe caller stops answering.
Stage fieldMeaning
nameStage name in the CAD. Stage 1 is the incident as created.
need / add / hiddenReplace the need / add to it / add units revealed only on scene.
work, fail, points, priority, category, transport, escalate, note, radioValues for the new stage.

Each next stage starts when progress reaches 100 %; units stay on scene and missing units are requested over the radio. dynamics.events and dynamics.stages must be on.

Test it right away:

Server console
dispatchtest jewelry_after_hours
server.lua
exports['koja-dispatcher']:CreateCall({
    scenario = 'jewelry_after_hours',
    coords = vector3(-630.4, -236.7, 38.1),
    label = 'Vangelico, Rockford Hills',
})

Scenarios can also be made in the in-game scenario editor in /dispatchadmin, which stores them in the database.


Register your own admin setting

RegisterSetting adds your own keys to the dispatcher's admin menu (Modules page). Admins change them in /dispatchadmin, the values are saved with the other settings and survive restarts, and every resource can read them with GetSetting.

  1. Register the keys

    your-resource/server.lua
    local dispatch = exports['koja-dispatcher']
    
    local function registerSettings()
        dispatch:RegisterSetting('myserver.robberyAlerts', 'bool', true)
        dispatch:RegisterSetting('myserver.robberyUnits', 'int', 2, {
            min = 1, max = 8,
            onChange = function(value, previous)
                print(('robbery units %s -> %s'):format(previous, value))
            end,
        })
        dispatch:RegisterSetting('myserver.robberyRoute', 'enum', 'incident', { values = { 'call', 'incident' } })
        dispatch:RegisterSetting('myserver.cooldown', 'number', 10, { min = 1, max = 60, step = 0.5 })
        -- client = true also sends the value to every client
        dispatch:RegisterSetting('myserver.showHint', 'bool', false, { client = true })
    end
    
    -- on your resource start (also after a restart: re-registering returns true
    -- and attaches the new onChange handler)
    registerSettings()
    
    -- and again when koja-dispatcher restarts after your resource
    AddEventHandler('onResourceStart', function(name)
        if name == 'koja-dispatcher' then registerSettings() end
    end)
    The group is the part before the first dot (myserver) unless you pass group. Saved values are restored even though your resource registers the keys after koja-dispatcher loaded its settings. On a re-register only onChange changes; type, range, default and group stay as first registered until koja-dispatcher restarts.
  2. Add the labels

    Add the group title and the labels to koja-dispatcher/locales/en.json (and your other locales) under ui.admin:
    locales/en.json
    {
      "ui": {
        "admin": {
          "module": {
            "myserver": "My server"
          },
          "set": {
            "myserver": {
              "robberyAlerts": "Robbery alerts",
              "robberyAlerts_hint": "Store robberies alert the dispatcher.",
              "robberyUnits": "Robbery units",
              "robberyUnits_hint": "Police units sent to a store robbery.",
              "robberyRoute": "Robbery alerts become",
              "robberyRoute_hint": "call rings at the console, incident goes straight to the map.",
              "cooldown": "Robbery cooldown (min)",
              "cooldown_hint": "Minutes between robberies of the same store.",
              "showHint": "Show robbery hint",
              "showHint_hint": "Shows a hint to robbers."
            }
          }
        }
      }
    }
    Merge these keys into the existing objects; do not replace the file.
  3. Read the values

    your-resource/server.lua
    local function robberyAlert(coords)
        if not dispatch:GetSetting('myserver.robberyAlerts') then return end
        local units = dispatch:GetSetting('myserver.robberyUnits')
        if dispatch:GetSetting('myserver.robberyRoute') == 'incident' then
            dispatch:CreateIncident({ category = 'robbery', coords = coords, need = { police = units }, autoAssign = true })
        else
            dispatch:CreateCall({ coords = coords, category = 'robbery', need = { police = units }, message = 'Store robbery' })
        end
    end
    
    -- other resources can react to the same key with the event
    AddEventHandler('koja-dispatcher:settingChanged', function(change)
        if change.key == 'myserver.robberyAlerts' then print('robbery alerts: ' .. tostring(change.value)) end
    end)