Skip to content

Configuration ​

Everything is in config.lua at the resource root. It is a shared script, so it is loaded on the client and the server, and every player can read it. Secrets such as the Discord webhook go in a server convar instead (see Installation → Discord webhook).

General ​

OptionDefaultDescription
Config.Helicopters{ polmav = true, buzzard2 = true }Models that have the camera, by lowercase spawn name (matched by model hash on client and server). false turns one off
Config.CameraSeats{ [-1] = true, [0] = true }Seats that can use the camera (-1 pilot, 0 copilot). false turns a seat off
Config.CanUsefunction(ped, heli) return true endClient-side permission hook. Return false to deny. See Installation
Config.Department'LOS SANTOS POLICE DEPARTMENT'HUD header
Config.Callsign'AIRSURV'HUD header, before the air callsign

Framework & roles ​

lua
Config.Framework = 'auto'           -- 'auto' | 'qbx' | 'qb' | 'esx' | 'standalone'
Config.Jobs = {
  air = { 'police', 'sheriff', 'lspd', 'bcso', 'sasp' },
  ground = { 'police', 'sheriff', 'lspd', 'bcso', 'sasp', 'ambulance' },
  bolo = { 'police', 'sheriff', 'lspd', 'bcso', 'sasp' },
  requireDuty = true,               -- qb/qbx: job.onduty must be true
  standaloneAllow = true,           -- standalone (no framework): everyone holds every role, like v1
}
Config.Callsigns = {
  air = 'AIR-1',                    -- shown in the header / to ground units
  perModel = { buzzard2 = 'AIR-2' },
  fallback = 'UNIT-%d',             -- ground units without a framework callsign (%d = server id)
}

Roles: air operates the camera, ground gets the Air Support panel, markers and alerts, and bolo manages the BOLO list with /bolo (police only by default, so EMS keep the panel but can't flag plates). With standaloneAllow = false, standalone servers grant the roles through the ACE permissions np_helicam.air, np_helicam.ground and np_helicam.bolo. See Installation → Roles.

The air callsign belongs to the airframe: perModel[model], otherwise air. Ground units use their framework callsign (qb / qbx metadata.callsign, except the default NO CALLSIGN), otherwise fallback.

Camera ​

lua
Config.Camera = {
  fovMax = 70.0,          -- widest zoom
  fovMin = 3.0,           -- tightest zoom
  zoomStep = 5.0,         -- fov change per scroll tick
  zoomLerp = 0.12,        -- fov smoothing per frame
  sensitivity = 4.0,      -- gimbal degrees per input unit at fovMax (scales down with zoom)
  pitchMin = -89.0,
  pitchMax = 45.0,
  attachOffset = { x = 0.0, y = 2.4, z = -1.2 },
  lockBreakDist = 900.0,  -- auto-unlock beyond this range
  lockLerp = 0.15,        -- tracking smoothing per frame
  probeRange = 4000.0,    -- crosshair raycast length
  cctvEffect = false,     -- true = grainy CCTV timecycle while the cam is active
  timecycle = 'heliGunCam',
}

Zoom is shown as magnification: tan(fovMax / 2) / tan(fov / 2), so 1× at 70° and about 23× at 3°.

sensitivity is the fallback. The value in use is the player's sensitivity setting (default 4.0, see Settings). cctvEffect = true forces the grain for everyone. Otherwise each player can turn it on in F9. attachOffset is also used by the live feed viewer.

HUD ​

lua
Config.Hud = {
  showTargetSpeed = true,  -- speed row on the target card
  headingArrow = true,     -- heli heading caret on the compass tape + arrow by the crosshair
  colors = {               -- HUD colour options a player can pick in F9
    white = { 255, 255, 255 },
    green = { 120, 255, 140 },
    amber = { 255, 196, 70 },
  },
  timeline = true,         -- mission timeline strip along the bottom edge
  maxMarkersDrawn = 12,
  maxUnitsDrawn = 16,
}

Adding an entry to colors adds it to the F9 colour picker. See HUD.

Orbit ​

lua
Config.Orbit = {
  doubleTapWindow = 400,  -- ms between lock presses to count as a double-tap
  radius = 120.0,         -- m, orbit radius around the locked position
  speed = 25.0,           -- m/s autopilot max speed
  height = 60.0,          -- m above the locked position
  direction = 'left',     -- LAPD-style left-hand orbit; 'right' flips it
  radiusMin = 50.0,       -- m, bounds for the per-player orbit radius (F9 / LSHIFT+scroll)
  radiusMax = 400.0,
  radiusStep = 10.0,      -- m per LSHIFT+scroll tick while orbiting
  leadAngle = 30.0,       -- deg ahead on the circle the autopilot chases
  retaskInterval = 1000,  -- ms between carrot updates (floored at 250)
}

In v2 the radius and direction come from the player's orbitRadius and orbitDirection settings (defaults 120 and 'left'), clamped to radiusMin–radiusMax. radius and direction here are only fallbacks.

The autopilot steers by re-issuing a GoTo task toward a point leadAngle degrees ahead on the circle every retaskInterval ms. It doesn't use the engine's circle task, because that task never runs its sustained phase on a player-controlled ped.

Overlay ​

lua
Config.Overlay = {
  streetLookupsPerFrame = 25, -- native street lookups per frame
  maxLabels = 90,             -- labels drawn at once
  labelGridRadius = 6,        -- samples a (2r+1)^2 grid of rays across the visible view
  labelSpacingMin = 40.0,     -- m, label cell size scales with camera height
  labelSpacingMax = 250.0,
  maxSampleDist = 2500.0,     -- how far ahead labels are sampled on grazing / horizon rays
  labelCellFactor = 2.0,      -- same-street labels repeat every labelCellFactor x spacing
  labelMarginX = 0.006,       -- min horizontal gap between labels (screen units)
  labelMarginY = 0.004,       -- min vertical gap
  crossingPriorityM = 100.0,  -- "A & B" junction labels win ties within this much extra depth
  gridHalfLines = 10,         -- GEO+ grid is (2n+1) lines per axis
  gridSpacing = 100.0,
}

Labels are sampled across the visible view frustum out to maxSampleDist, so they cover what is on screen at any camera angle. To lower the overlay's cost, reduce streetLookupsPerFrame and maxLabels first.

Searchlight ​

lua
Config.Searchlight = {
  radius = 14.0,
  distance = 300.0,       -- beam length
  brightness = 10.0,
  hardness = 0.4,
  falloff = 18.0,
  shadowDist = 250.0,     -- shadowed light within this range of the local player
  maxDist = 800.0,        -- beyond this, don't render at all
  color = { 255, 255, 255 },
  beams = {               -- J cycles; radius/falloff per beam
    narrow = { radius = 14.0, falloff = 18.0 },
    wide = { radius = 32.0, falloff = 10.0 },
  },
  strobeHz = 4.0,         -- strobe blink rate
  minSendInterval = 100,  -- ms; max 10 Hz state bag writes
  heartbeat = 5000,       -- ms
  angleThreshold = 1.5,   -- degrees
  slerpRate = 0.15,       -- receiver direction smoothing per frame
}

Only the two beam names narrow and wide exist on the wire. A beam missing from beams falls back to the top-level radius / falloff. The last four options control replication. See Searchlight sync.

Hover ​

lua
Config.Hover = {
  autoEngageSolo = true,   -- auto-engage when a solo pilot opens the camera
  minAltitude = 15.0,      -- m above ground required to engage, and the floor the hold won't descend below
  climbRate = 3.0,         -- m/s of anchor travel from the collective (W/S)
  nudgeRate = 4.0,         -- m/s of lateral anchor travel from the roll keys (NUM4/NUM6)
  gain = 1.6,              -- position error -> commanded velocity
  damping = 0.18,          -- velocity easing per 60 fps frame
  maxCorrection = 12.0,    -- m/s clamp on the servo's corrective command
  maxEngageSpeed = 12.0,   -- m/s; refuse to engage above this (clamped to maxCorrection)
  maxLead = 40.0,          -- m; longest glide-out to the hold point when engaging with speed on
  pitchDeadzone = 0.15,    -- cyclic pitch magnitude that hands control back
  drift = {
    horiz = 0.45,          -- m, horizontal sway envelope
    vert = 0.22,           -- m, vertical bob envelope
    periods = { 7.3, 11.7, 5.1 },  -- s, incommensurate on purpose
  },
}

The hold only sets linear velocity. Rotor, engine, angular physics and collisions stay with the game, so the airframe still banks when you nudge it. autoEngageSolo is the server switch. With it on, each player can still turn auto-hover off for themselves (autoHoverSolo in F9).

Player settings ​

lua
Config.Settings = {
  defaults = {
    units = 'imperial',             -- 'imperial' | 'metric'
    hudColor = 'white',             -- key of Config.Hud.colors
    reticle = 'cross',              -- 'cross' | 'box' | 'dot'
    hudScale = 1.0,                 -- 0.8 .. 1.3
    keyHints = true,
    highContrast = false,           -- dark backing behind HUD text and labels
    cctv = false,                   -- grainy timecycle (Config.Camera.timecycle)
    sensitivity = 4.0,              -- 1 .. 8
    invertY = false,
    autoHoverSolo = true,
    orbitRadius = 120.0,
    orbitDirection = 'left',
    lightFollowsCam = true,         -- searchlight slaved to the camera
    shareFeed = true,               -- let ground units watch the feed
    thermalPalette = 'whitehot',
    showContextMap = true,          -- minimap inset while the camera is up
    mapFollow = 'both',             -- Air Support map: 'both' | 'heli' | 'me' | 'target' (set in the panel)
    mapZoom = 0,                    -- Air Support map zoom steps, -3 .. 3 (set in the panel)
  },
  locked = {},                      -- e.g. { 'shareFeed' }
}

These are the defaults for the F9 sheet. Keys listed in locked always use the default and can't be changed by players. See Player settings.

Thermal ​

lua
Config.Thermal = {
  warmupMs = 8000,                  -- FLIR unavailable this long after the camera opens
  palettes = {                      -- Y cycles in this order. Values feed SEETHROUGH_* natives.
    { id = 'whitehot', label = 'WHT-HOT', near = { 0, 0, 0 }, heatscale = { 0.0, 0.1, 0.5, 1.0 },
      noiseMin = 0.0, noiseMax = 0.1, hiIntensity = 0.5, hiNoise = 0.4, maxThickness = 1.0 },
    { id = 'blackhot', label = 'BLK-HOT', near = { 255, 255, 255 }, heatscale = { 1.0, 0.6, 0.2, 0.0 },
      noiseMin = 0.0, noiseMax = 0.1, hiIntensity = 0.0, hiNoise = 0.2, maxThickness = 1.0 },
    { id = 'ironbow', label = 'IRONBOW', near = { 40, 0, 80 }, heatscale = { 0.0, 0.25, 0.6, 1.0 },
      noiseMin = 0.05, noiseMax = 0.2, hiIntensity = 0.8, hiNoise = 0.5, maxThickness = 1.0,
      timecycle = 'NG_filmic01' },
    { id = 'edge', label = 'EDGE', near = { 0, 0, 0 }, heatscale = { 0.0, 0.0, 0.8, 1.0 },
      noiseMin = 0.0, noiseMax = 0.0, hiIntensity = 1.0, hiNoise = 0.0, maxThickness = 3.0 },
  },
}

Each palette is a preset for the game's single thermal renderer:

FieldNative
near (RGB)SeethroughSetColorNear
heatscale (4 bands, coolest first, 0–1)SeethroughSetHeatscale, mapped onto the native's 0–0.75 range
noiseMin / noiseMaxSeethroughSetNoiseAmountMin / Max
hiIntensity / hiNoiseSeethroughSetHiLightIntensity / Noise
maxThicknessSeethroughSetMaxThickness (1–10000)
fadeStart / fadeEnd (optional)SeethroughSetFadeStartDistance / EndDistance
timecycle (optional)SetTimecycleModifier while that palette is active

The engine is reset (SeethroughReset) when FLIR is left, the camera closes or the resource stops. A new palette's id is picked up by the F9 palette list automatically.

Verify in-game

The palette values are a starting point and have not been tuned in-game. Black-hot is an approximation: there is no real inversion native.

ANPR, heat and trail ​

lua
Config.Anpr = {
  readMs = 1500,                    -- steady lock needed before the plate is read
  maxRange = 450.0,                 -- m
  minZoomX = 4.0,                   -- magnification needed to read a plate
}

Config.Heat = {
  scanPerFrame = 24,                -- entities examined per frame (round robin)
  max = 24,                         -- signatures boxed at once
  maxRange = 900.0,                 -- m
}

Config.Trail = { intervalMs = 750, minSpacing = 6.0, max = 40 }

The ANPR timer resets whenever range, zoom or line of sight drop out. minZoomX also feeds the lock quality score. Config.Trail samples the locked target every intervalMs, skips points closer than minSpacing m, and keeps the last max points. See Operations.

BOLO ​

lua
Config.Bolo = {
  cacheMs = 60000,                  -- client cache per plate
  bannerMs = 8000,                  -- red banner on the HUD
  -- Server hook for an external MDT: return { reason = '...', code = '10-32' } or nil.
  lookup = nil,                     -- function(plate) ... end
  commandJobs = 'bolo',             -- role allowed to use /bolo ('bolo' | 'ground' | 'air')
}

cacheMs is also the server's window for de-duplicating alerts (one alert per plate per heli). lookup runs on the server and receives the normalised plate (upper case, letters and digits only):

lua
Config.Bolo.lookup = function(plate)
  local hit = exports.my_mdt:GetWarrantForPlate(plate) -- your MDT
  if hit then return { reason = hit.title, code = '10-32' } end
end

config.lua is shared, so the function is also loaded on clients, but it is only called on the server. Don't put credentials in it. commandJobs is a role ('bolo', 'ground' or 'air'), not a job list.

The server reads the plate itself from the vehicle the operator locked (by network id, within Config.Anpr.maxRange + 50 m of the heli), so a client can't check arbitrary plates. At most 2 checks per second per operator reach lookup. The in-memory registry holds at most 500 BOLOs.

Markers ​

lua
Config.Markers = {
  max = 12,                         -- air-placed markers per server (min 1); oldest is dropped.
                                    -- Ground "orbit on me" requests have their own 4 slots
                                    -- (one per requester) and never push out an air marker.
  ttlMin = 30,                      -- auto-expire
  labelMax = 32,                    -- characters
  rateMs = 750,                     -- per player
}

Units ​

lua
Config.Units = {
  intervalMs = 1000,                -- position broadcast while somebody is watching
  includeAir = true,
}

The broadcast only runs while at least one player is subscribed (an operator with the camera up, or a ground unit with the panel open). intervalMs is floored at 250. includeAir = false leaves players in helicopters out.

Mission ​

lua
Config.Mission = {
  autoRecord = true,                -- start recording when the camera opens
  maxEvents = 200,
  trackIntervalMs = 5000,           -- flight track sample rate
  idleEndMs = 120000,               -- report is finalised this long after the cam closes
  screenshots = true,               -- B also takes an evidence screenshot (needs screenshot-basic)
  -- Discord webhook comes from the server convar `np_helicam_webhook`.
}

trackIntervalMs is floored at 1000 on the server. A mission shorter than 10 s with at most one real event is discarded (no report, event or webhook), so quick E taps with autoRecord don't flood your channel. One mission start per 10 s per operator. When the event list is full, the oldest event after the start line is dropped and counted as "trimmed" in the report.

Feed ​

lua
Config.Feed = {
  maxViewersPerHeli = 8,
  cullRadius = 1500.0,              -- OneSync culling radius for helis being watched
  sendIntervalMs = 200,             -- helicam_cam max rate (5 Hz)
  angleThreshold = 0.75,            -- deg
  fovThreshold = 0.5,               -- deg
  heartbeat = 3000,
  statusIntervalMs = 1000,
}

The operator sends its camera state to the server when the gimbal moved more than angleThreshold, the FOV more than fovThreshold, the vision or palette changed, or heartbeat ms passed, but never faster than sendIntervalMs. The server then writes helicam_cam. The status summary is sent every statusIntervalMs (at least 1000) and is kept on the server, not in a state bag. See Events → State bags.

Context map ​

lua
Config.ContextMap = {
  zoomMin = 900,                    -- SetRadarZoom at low altitude (camera inset, top-right)
  zoomMax = 1400,
  -- Minimap layout put back when the radar is released (camera closed AND Air Support panel
  -- closed): { posX, posY, sizeX, sizeY } per component, 'L'/'B'-aligned. Defaults are
  -- GTA's own (frontend.xml).
  restore = {
    minimap      = { -0.0045, 0.002, 0.150, 0.188888 },
    minimap_mask = { 0.020, 0.032, 0.111, 0.159 },
    minimap_blur = { -0.03, 0.022, 0.266, 0.237 },
  },
}

The zoom moves from zoomMin at 50 m above ground to zoomMax at 500 m and above.

When the radar is released (the camera is closed and the Air Support panel is closed), the minimap layout from restore is put back. A missing or malformed component falls back to GTA's default. np_hud re-applies its own layout by itself. Another HUD that moves the minimap should either copy its values into restore, or listen for the client event np_helicam:contextMap (true when np_helicam takes the minimap, false right after it restored it) and re-apply its own layout.

Map ​

The Air Support panel map and the camera's context map both show the game's own radar. Config.Map controls the panel map, Cayo Perico and map regions:

lua
Config.Map = {
  mode = 'radar',                   -- 'radar' (game radar under the panel) | 'svg' (abstract tactical map)
  -- Cayo Perico island map:
  --   'auto'   switch the radar to the island map while the radar centre is over the island
  --            (SetUseIslandMap + SetRadarAsInteriorThisFrame) and back when leaving / closing
  --   'always' the same, but never switch the island map OFF (for servers that keep it on
  --            with another resource)
  --   'off'    never touch it (the heistIsland entries below are ignored)
  cayo = 'auto',
  -- Width / height of the panel window. GTA's own minimap is ~1.41 (MapMath.NATIVE_ASPECT);
  -- if the radar looks stretched in-game, set 1.41.
  windowAspect = 1.6,
  hideHealthArmour = true,          -- hide the radar's health/armour bars while the panel owns it
  -- minimap_blur under the panel window: 'fit' = exactly under the map (invisible), 'scaled' =
  -- GTA's proportions (a dark plate ~1.8x the map that spills over the world beside the panel)
  blur = 'fit',
  clipType = 0,                     -- SetMinimapClipType while we own the radar: 0 rectangular, 1 round, nil = leave
  restoreClipType = nil,            -- clip type to put back on release (nil = leave as is; the game default is 0)
  updateMs = 250,                   -- panel view (centre / zoom target) recomputed at ~4 Hz
  centerRate = 6.0,                 -- 1/s exponential approach of the radar centre (smooth follow)
  snapDist = 1500.0,                -- m; a jump further than this snaps instead of sliding
  fitPadding = 1.5,                 -- 'both' view: margin around me + heli + aim + target (clears the overlay chips)
  minHalf = 120.0,                  -- m, closest view (centre to top edge)
  maxHalf = 4000.0,                 -- m, widest view
  followHalf = 450.0,               -- m, view size for heli / me / target follow at zoom step 0
  zoomStepFactor = 1.6,             -- each −/+ step divides / multiplies the view by this
  -- SetRadarZoom level -> metres from the radar centre to its top/bottom edge. APPROXIMATE:
  -- community values and eyeballing, there is no documented scale (the native docs say
  -- 0..200, scripts use 0..1400; 0 = the game's own speed-based zoom). The radar draws the
  -- same world area into any component size, so this holds for the panel window too. Tune
  -- in-game: stand at a junction, compare a known distance with the window.
  zoomFit = {
    { zoom = 0, half = 110 },
    { zoom = 200, half = 210 },
    { zoom = 400, half = 360 },
    { zoom = 600, half = 540 },
    { zoom = 800, half = 780 },
    { zoom = 1000, half = 1080 },
    { zoom = 1200, half = 1500 },
    { zoom = 1400, half = 2050 },
  },
  -- Map regions, checked against the radar CENTRE (the player when the radar follows them),
  -- first match wins. Fields: name, minX, minY, maxX, maxY (world bounds); interior = hash
  -- or name + ix, iy -> SetRadarAsInteriorThisFrame(interior, ix, iy, 0, 0) every frame
  -- while inside; heistIsland = true -> SetUseIslandMap(true) (see `cayo` above).
  -- Map extensions that stream minimap tiles (Roxwood etc.) need NO entry: their tiles are
  -- part of the exterior radar. Add one only if the extension ships its own radar interior.
  regions = {
    { name = 'cayo_perico', minX = 3500.0, minY = -6300.0, maxX = 6000.0, maxY = -4000.0,
      interior = 'h4_fake_islandx', ix = 4700.0, iy = -5145.0, heistIsland = true },
    -- Roxwood County (The Ambitioneers) -- example only, PLACEHOLDER bounds: the expansion
    -- sits beyond the vanilla north-west coast, reached by a bridge near Paleto Bay; check
    -- the real extent with /coords on your server. Its minimap tiles (Extra Map Tiles /
    -- minimap_manager) already draw into the radar, so a region is only useful to give
    -- it a radar interior of its own, which Roxwood does not need:
    -- { name = 'roxwood', minX = -4000.0, minY = 7000.0, maxX = 1500.0, maxY = 11000.0 },
  },
  -- Blip styles (sprite / colour ids: docs.fivem.net/docs/game-references/blips). Shown on
  -- the radar only (SetBlipDisplay 5), hidden on the legend, removed on release.
  blips = {
    heli     = { sprite = 43, colour = 0, scale = 0.9, priority = 13 },   -- radar_police_heli, rotates with heading
    aim      = { sprite = 1, colour = 0, scale = 0.55, priority = 11 },   -- radar_level, white
    target   = { sprite = 1, colour = 1, scale = 0.85, priority = 12 },   -- red
    marker   = { sprite = 1, colour = 17, scale = 0.7, priority = 10 },   -- orange PNT
    orbitReq = { sprite = 1, colour = 5, scale = 0.8, priority = 10 },    -- yellow ORBIT REQ
    unitCar  = { sprite = 56, colour = 3, scale = 0.7, priority = 9 },    -- radar_cop_patrol, blue
    unitFoot = { sprite = 3, colour = 3, scale = 0.7, priority = 9 },     -- radar_police_ped, blue
  },
}
KeyDefaultEffect
mode'radar''radar': the game's radar under a transparent window in the panel. 'svg': the abstract tactical map drawn by the NUI (the radar is not touched)
cayo'auto'Cayo Perico island map: 'auto', 'always' or 'off'. See Air Support → Map extensions
windowAspect1.6Width / height of the panel window. GTA's own minimap is about 1.41: use that if the radar looks stretched in game
hideHealthArmourtrueHides the radar's health and armour bars while the panel owns it
blur'fit''fit': the radar's blur plate sits exactly under the map. 'scaled': GTA's proportions, a dark plate about 1.8× the map that shows beside the panel
clipType0SetMinimapClipType while np_helicam owns the radar: 0 rectangular, 1 round, nil leaves it
restoreClipTypenilClip type put back on release. nil leaves it as is (the game default is 0)
updateMs250How often the panel view (centre, zoom, blips) is recomputed
centerRate6.0Speed (1/s) of the smooth slide to a new centre
snapDist1500.0A jump further than this (m) snaps instead of sliding
fitPadding1.5Margin around you, the heli, the aim point and the target in the Both view
minHalf / maxHalf120.0 / 4000.0Closest and widest view, in metres from the centre to the top edge
followHalf450.0View size for the Heli, Me and Target modes at zoom step 0
zoomStepFactor1.6Each + / − step divides or multiplies the view by this
zoomFittableRadar zoom level → metres from the centre to the top/bottom edge. Interpolated linearly, clamped at both ends
regionsCayo PericoMap regions checked against the radar centre, first match wins. See Custom regions
blipssee aboveSprite, colour, scale and priority per blip kind. Sprite and colour ids: FiveM blip reference

zoomFit is approximate

The radar zoom has no documented scale. The values come from community scripts and eyeballing. To tune them, stand at a junction in game, compare a known distance with the panel window and adjust the half values.

The follow mode and zoom steps players pick in the panel are stored as the mapFollow and mapZoomplayer settings. They are not listed in the F9 sheet, but you can set their defaults or lock them like any other setting.

Notifications & prompt ​

lua
Config.Notify = {
  provider = 'auto',                -- 'auto' (built-in Nimbus toasts) | 'np_hud' | 'ox_lib' | 'native'
}

Config.Prompt = { showForMs = 8000 } -- boarding prompt after entering an eligible seat
ProviderUses
'auto'The built-in Nimbus toasts in np_helicam's NUI
'np_hud'exports.np_hud:Notify({ type, tint, icon, app, title, message, duration })
'ox_lib'exports.ox_lib:notify({ title, description, type, duration, icon })
'native'A GTA feed post

When the configured np_hud or ox_lib is not started, or its export fails, the built-in toasts are used.

np_* FiveM resources