API overview
koja-dispatcher exposes five integration surfaces:
| Surface | Side | Use it to |
|---|---|---|
| Server exports | server | Ring calls, create and manage incidents, move units, read dispatchers, change and register settings, create BOLOs, log to Discord, report plates. |
| Client exports | client | Check whether the local player is on shift, raise an alert or a /911 call from a client script. |
| Hooks | server | Block or rewrite calls, incidents, assignments and shifts before they happen, and react after. |
| Events | both | Listen to dispatcher activity with plain AddEventHandler, feed phones and other dispatch scripts in. |
| Settings | server | Every key GetSetting and SetSetting accept, with ranges and defaults. |
The examples page has complete, copy-paste recipes.
Getting started
-
Start order
Start your resource afterkoja-dispatcher(and its dependenciesoxmysqlandkoja-lib).server.cfgensure oxmysql ensure koja-lib ensure koja-dispatcher ensure my-robberies -
Call an export
The resource folder must be namedkoja-dispatcher: the folder name is the export namespace.server.lualocal dispatch = exports['koja-dispatcher'] print(('%d dispatcher(s) on shift'):format(dispatch:GetDispatcherCount())) -
Handle the result
Exports that create something returnid, routeon success andnil, errorCodeon failure. Always check the first value.server.lualocal id, route = exports['koja-dispatcher']:CreateCall({ coords = vector3(24.5, -1347.3, 29.5), caller = 'Store alarm', message = 'Silent alarm at the 24/7 on Innocence Blvd', }) if not id then print('911 call failed: ' .. tostring(route)) -- route holds the error code end
Concepts
| Thing | What it is |
|---|---|
| Call | A line at the dispatcher console. kind = 'script' is a generated caller with a scripted (or AI) conversation; kind = 'player' is a text line from /911, a phone, an export, a dispatch-script alert or koja-mdt. A dispatcher answers the call, questions the caller and dispatches it, which turns it into an incident. |
| Incident | A job on the map. It has a category, priority 1 to 3, a need (units per department), progress, danger, assigned units and units on scene. It ends as resolved, failed or cancelled. |
| Unit | An AI unit from the fleet (ai:<n>) or a player on duty in a department (p:<serverId>). Departments are police, ems and fire. |
| Dispatcher | A player on shift at a station console, or with the remote CAD (/cad) when access.remote is on. |
Call lifecycle
| State | Meaning | Next |
|---|---|---|
ringing | Waiting at the console. Generated calls hang up after calls.patience seconds (callMissed). | talking |
talking | A dispatcher answered (callAnswered). Questions, instructions and replies happen here. | done |
done | Dispatched (the call's incident field holds the new incident id), hung up, marked as a prank or expired (callEnded). Removed 4 s later. |
Player calls also end on their own when nobody touched them for 3 times calls.playerReplyWindow. Within calls.playerReplyWindow a second /911 from the same player is added to the open call instead of creating a new one.
Call ids start at 1 and count up per server start.
Incident lifecycle
| Status | Meaning |
|---|---|
active | On the map. Units drive to it; when they are on scene, progress fills at a speed set by how much of the need is covered. Uncovered time raises danger (exposure). Scenarios can escalate (more units, priority 1) when danger passes a threshold. |
resolved | Progress reached 100 %, a field unit sent code 4, ResolveIncident was called, the MDT call was closed (outcome = 'mdt') or a prank was uncovered (outcome = 'prank'). Multi-stage scenarios start the next stage instead of resolving. |
failed | Danger reached the incident's fail time, or a pursuit suspect got away (outcome = 'lost'). |
cancelled | A dispatcher or CancelIncident cancelled it (outcome = 'cancelled', or 'prank' for a prank call). |
Ended incidents stay in GetIncidents() for 20 seconds (Config.Incidents.lifetimeAfterEnd), then disappear. Incident ids start at 1001.
An incident whose caller did not give the location yet is located = false: it sits at an approximate point (up to Config.Calls.approximateRadius = 300 m off). Units that arrive there first search (searching) before they find the scene.
Incidents created from a player (/911 fallback, follow field) follow that player until the first unit is on scene.
Unit lifecycle and statuses
| Status | Meaning |
|---|---|
available | Free. Can be assigned. |
enroute | Driving to an incident or to a staging point. |
searching | Arrived at an approximate location, looking for the scene. |
onscene | Working the incident. |
transport | EMS unit driving a patient to a hospital. |
returning | AI unit driving back to its station. Can be assigned again. |
busy | Set by hand (CAD, field code 10-6, SetUnitStatus). Not assignable. |
unavailable | Set by hand (field code 10-7, SetUnitStatus). Not assignable. |
offline | AI unit off duty: mode.units = 'players', or enough players of its department are on duty (units.disableWhenPlayers). |
resting / repair | AI unit resting or in repair at its station (fleet module, fleet.enabled). |
AI units follow the road graph; air units fly straight. Player units are real players and their position is read from their ped.
Unit purposes (the purpose field): incident, move (staging point), home, hospital, handover, patrol.
Dispatcher lifecycle
A dispatcher starts a shift at a console (shiftStart hook, then shiftStarted), works calls and incidents, and ends it (shiftEnded) with a summary: minutes, stats, a 1 to 5 star rating and pay. Leaving the server ends the shift without pay. A player on shift is never a field unit at the same time.
The call router
CreateCall, /911, phone calls, the client SendAlert export and dispatch-script alerts in call mode all go through one router:
| Step | Check | Result | Returned |
|---|---|---|---|
| 1 | At least one dispatcher is on shift and mode.calls is not generated | The call rings at the console (callCreate, callCreated). | callId, 'call' |
| 2 | Otherwise, mode.noDispatcher = 'auto' | An incident is created at once and the nearest free units are auto-assigned. The caller gets a "units dispatched" notification. | incidentId, 'incident' |
| 3 | Otherwise, mode.noDispatcher = 'mdt', koja-mdt is running and the call did not come from koja-mdt | The call is created as a koja-mdt dispatch call. | mdtCallId, 'mdt' |
| 4 | Anything else (reject, or the MDT is down) | Nothing is created. | nil, 'no_dispatchers' |
If a callCreate hook blocks the call in step 1, the router returns nil and the hook's reason (cancelled when the hook gave none). If an incidentCreate hook blocks the incident in step 2, the router returns nil, 'cancelled'.
Admins switch modes in game with /dispatchadmin. Defaults live in Config.Modes (calls = 'mixed', units = 'mixed', noDispatcher = 'reject').
Data formats
Coordinates
Every coords parameter accepts:
| Form | Example |
|---|---|
vector3 / vector4 | vector3(24.5, -1347.3, 29.5) |
| table with keys | { x = 24.5, y = -1347.3, z = 29.5 } |
table without z | { x = 24.5, y = -1347.3 } (z becomes 0.0) |
| array | { 24.5, -1347.3, 29.5 } |
x and y must be within ±9000, otherwise the export returns nil, 'invalid'. CreateCall and CreateIncident also take source = serverId instead of coords to use that player's position.
Needs
need is how many units of each department an incident requires:
need = { police = 2, ems = 1, fire = 0 }
Only police, ems and fire are read. Values are floored and capped at 8 per department; zero and negative values are dropped. An incident with an empty need gets { police = 1 }. Unit types have a power (Config.UnitTypes): SWAT and ladder trucks count as 2.
An incident has two needs: need (the truth) and known (what the dispatcher knows). Exports set both to the same value; generated calls reveal the rest through questions or on scene.
Categories
robbery, burglary, assault, shooting, domestic, disturbance, suspicious, medical, overdose, fire, traffic, rescue, hostage, pursuit, officer_down, hazmat.
The list is Scenarios.Categories in shared/scenarios.lua. An unknown category is ignored by CreateCall and UpdateIncident and becomes suspicious in CreateIncident.
Priority
1 (highest) to 3. Values are clamped. Priority 1 incidents are announced as alerts on the radio.
Unit references
Unit exports take a unit reference:
| Reference | Meaning |
|---|---|
'ai:3' | AI unit 3 of Config.AiUnits.fleet (the index in the list). |
'ai:d12' | AI unit with database row 12, when the fleet was edited in the in-game fleet editor. |
'p:12' | The player unit of server id 12. |
12 (number) | The same as 'p:12': the player unit of that server id. |
Unit ids depend on the fleet configuration, so look them up with GetUnits() (for example by callsign) instead of hard-coding them.
Return conventions
| Export kind | Success | Failure |
|---|---|---|
Create (CreateCall, CompatAlert, PhoneCall, client SendAlert) | id, route | nil, errorCode (client exports: false, errorCode) |
CreateIncident, CreateBolo | the id | nil, errorCode |
CloseBolo, SetSetting, RunFieldCode | true | false, errorCode |
Actions (UpdateIncident, ResolveIncident, ReleaseUnit, SetCallsign, RegisterSetting, ...) | true | false |
Counters (AssignUnits, AutoAssign) | number of units assigned (0 when nothing happened) | 0 |
Lookups (GetCall, GetUnit, ...) | a table | nil |
Lists (GetUnits, GetIncidents, ...) | an array | an empty array |
Returned tables are copies: changing them does not change the dispatcher. Exports never throw on bad input.
route is 'call' (rings at the console), 'incident' (created as an incident), 'mdt' (sent to koja-mdt; the id is the MDT call id) or, for player calls added to an open line, 'update'.
Error codes
| Code | Returned by | Meaning |
|---|---|---|
invalid | most exports | Missing or malformed input (no coordinates, unknown scenario, bad key, message too short). |
no_dispatchers | call router | Nobody on shift and mode.noDispatcher did not create anything. |
cancelled | call router, CreateIncident, CompatAlert, client SendAlert, shift start | A before hook blocked the call, incident or shift. A hook can return its own code instead (return false, 'suspended'); that code is passed on in place of cancelled. |
disabled | SendAlert, PhoneCall, CompatAlert, RunFieldCode | The feature or setting is off (api.clientAlerts, phones.enabled, compat.enabled, mdt_status.enabled). |
no_response | client exports | The server did not answer within 10 s, or the request hit the rate limit. |
rate | PhoneCall | The same player called again within Config.Phones.cooldown (2 s). |
duplicate | CompatAlert | An identical alert came in within Config.Compat.dedupeSeconds (20 s). |
not_field_unit | RunFieldCode | The player is not a field unit or MDT officer on duty, or is a dispatcher. |
cooldown | RunFieldCode | Backup or panic used again within mdt_status.cooldown. |
offline / busy / unavailable / no_channel | ConnectVoice | Voice call cannot start: a player left, one of them is already in a voice call, no voice resource, or all channels are used. |
title_required / plate_required / invalid_plate / invalid_url / not_found / mdt_offline / mdt_failed / db | CreateBolo, CloseBolo | BOLO input or storage problem, see CreateBolo. |
Hooks and events
Every lifecycle point is published twice:
- As a hook:
RegisterHook(name, fn).beforehooks (callCreate,incidentCreate,unitAssign,shiftStart) run first and can block the action (with an optional reason) or, forincidentCreate, change it.afterhooks run when it happened. - As a server-local event:
koja-dispatcher:<name>for everyafterhook, fired right after the hook functions, with the same payload. Listen withAddEventHandler.
Use a hook when you need to block or rewrite something, or want automatic cleanup when your resource stops. Use the event for simple listeners. See Hooks and Events.
Server and client rules
| Topic | Rule |
|---|---|
| Side | Server exports can only be called from server scripts. Client exports talk to the server through a rate-limited RPC. |
| Trust | Server resources are trusted: server exports do not check permissions. Validate player input (distance, job, cooldown) in your own net events before you call them. |
| Yielding | Server exports do not yield, except the ones that write the database (SetSetting, CreateBolo, CloseBolo) or talk to koja-mdt. Client exports SendAlert and Call911 yield until the server answers; call them from a thread or an event handler. |
| Rate limits | Client RPCs are limited per player (Config.Security.limits). SendAlert: burst 3, then one every 5 s. Call911: burst 2, then one every 20 s. |
Own code inside the resource
If you need to change koja-dispatcher itself, put your own files in server/custom/*.lua and client/custom/*.lua. Both folders are loaded automatically, after all built-in modules, so KOJA.Server.* / KOJA.Client.* are ready when your file runs. Do not add files to server/features/: the manifest lists those files one by one, so a new file there is not loaded. Keep a copy of your custom files when you update.
Versioning
| Item | Value |
|---|---|
| Resource / export namespace | koja-dispatcher |
| Version | version in fxmanifest.lua (1.1.0 at the time of writing) |
| Product key | koja_resource 'koja-dispatch' in fxmanifest.lua, used by koja-lib to look up the latest release |
| Database tables | koja_dispatch_* |
On start the server prints a banner and, when Config.VersionCheck.enabled is on and koja-lib is started, asks koja-lib for the release status: up to date, development build, update available or critical update (with the first changelog lines and a download link).
Feature-detect from your own resource instead of assuming a version:
local state = GetResourceState('koja-dispatcher')
local version = GetResourceMetadata('koja-dispatcher', 'version', 0)
if state == 'started' then
print(('koja-dispatcher %s is running'):format(version))
end
-- an export that may not exist in older versions
local ok, id = pcall(function() return exports['koja-dispatcher']:CreateBolo(data) end)/dispatchcheck (server console or admins) runs a health check: koja-lib, oxmysql, framework, database tables, dispatcher jobs, AI fleet, web build, AI keys, settings, stations and the koja-mdt link.