ZoneAttackAlert

Version: v3.4.0

A DCS World Lua mission script that sends coalition-specific attack alerts when ground or ship units inside defined trigger zones are hit.

Overview

ZoneAttackAlert monitors the DCS World event stream for S_EVENT_HIT events. When a ground unit or ship inside a configured trigger zone is hit, the script broadcasts a location-aware alert message to the coalition that owns the actual unit being hit (read dynamically via target:getCoalition()). Blue coalition receives distances in nautical miles (NM) and English alert text; Red coalition receives distances in kilometers (KM) and German alert text. A per-zone, per-coalition cooldown timer prevents message spam.

Features

Logic Flow Diagrams

Initialization Flow

Script loadedReload guardZAA.loaded?Skip (return)Remove stale handler(_G.ZAA_HANDLER)Localize globals(capture hot-path refs)Build zone cache(geometry + bbox bounds)Build airbase cache(grouped by coalition)Warn on missing zonesInit state(last_alert_time empty)Register event handler(S_EVENT_HIT only)Log startup message YesNo

Event Handler Flow

onEvent(event)event.id == S_EVENT_HIT?pcall(handle_hit, event)target exists?target:getPoint() gives x, z?Inside zone bounding box?target is a unit?(getCategory == UNIT)unit category isGROUND_UNIT or SHIP?target:getCoalition()is BLUE or RED?find_zones(px, pz, now, coal)zone found & not on cooldown?send_alert(zone, eligible, coal, px, pz)Return silently NoYesNo targetYesNo pointYesOutside bboxYesStatic objectYesNot ground/shipYesNil / neutralYesYesNo / on cooldown

Airbase Distance Lookup

Alert triggeredfind_zones() returns zonereceive target_coalbuild_zone_loc(px, pz, target_coal)(hit position)Pick airbase set:own coalition, else allFind closest airbase(min squared distance)Compute distance (m)& cardinal bearingFormat string:Blue -> NM + English layoutRed -> KM + German layoutReturn loc_str target_coal == BLUEtarget_coal == RED

Cooldown Logic

find_zones(px, pz, now, coal)For each zone in zone_cachePoint inside radius?Cooldown OK forthis zone + coalition?Eligible candidate(keep nearest centre)Collect eligible zonesReturn nearest eligible zone+ eligible listReturn nil NoYesYesNo (on cooldown)End loopNo eligible zone

Message Construction Flow

send_alert(zone, eligible, coal, px, pz)Stamp cooldown on everyeligible zone (per coalition)loc_str = build_zone_loc(px, pz, coal)(hit position, not zone centre)Choose alert template:Blue template:'ALERT: Friendly forces under attack!' + loc_strRed template:'ALARM: Freundliche Kräfte unter Beschuss!' + loc_stroutTextForCoalition(target_coal, msg, duration)Message displayed target_coal == BLUEtarget_coal == RED

Changes in v3.4

Correctness

Change Description
Units only getDesc().category is polymorphic: for a static object it returns StaticObject.Category (WEAPON=2, STATIC=3), which numerically collides with Unit.Category (GROUND_UNIT=2, SHIP=3). Static objects and structures were therefore misread as ground units or ships and produced false alerts. The handler now checks the object category first (getCategory() == UNIT) and only then the unit sub-category.
Per-coalition cooldown Cooldowns are keyed by zone and coalition. Since v3.3 the recipient is derived from the unit that was hit, so a zone shared by both coalitions must not let one side silence the other.
Overlapping zones One hit in an overlapping area now produces a single alert, and the cooldown is stamped on every eligible zone containing the hit so a second alert cannot fire seconds later for the neighbouring zone. A zone that is already cooling down keeps its original expiry and is not extended.
Hit-position callouts The location string is built from the actual hit position rather than the zone centre, so the reported bearing and distance match where the shot landed.
Hot-reload cleanup The handler is kept in _G.ZAA_HANDLER and removed with world.removeEventHandler on re-init, so clearing _G.ZAA and re-running the script no longer leaves two handlers producing duplicate alerts. Because DCS does not always honour removeEventHandler, each instance also carries an epoch token (_G.ZAA_EPOCH); a handler whose token is stale stays silent, so a duplicate alert is impossible even if the removal fails.
Neutral units ignored Units owned by the neutral coalition no longer generate an alert addressed to a side they do not belong to.
Missing-zone warning Trigger zones that are not defined in the mission are reported via env.warning at startup instead of being silently ignored.

Performance

The hit handler runs on every S_EVENT_HIT event in the mission, so the work is ordered cheapest-first:

Change Description
Event id check outside pcall Only hit events pay for the protected call; previously a closure was allocated for every event in the game.
Bounding-box rejection A bounding box over all zones (centres expanded by the largest radius) rejects hits outside the monitored area with four numeric comparisons, before any further DCS API call.
getPoint() instead of getPosition() getPosition() wraps the point in a Vec3; getPoint() returns the point directly.
No descriptor table on the hot path getDesc() allocated a full descriptor table per hit. The unit sub-category is now read via getCategoryEx() (DCS 2.9.2+), with a getDesc() fallback only for older versions. The capability is probed once and cached, so no method lookup happens per hit.
Debug logging is free when disabled getName() and the formatted debug strings are only built when config.debug is true, instead of on every hit.
Airbase cache grouped by coalition The nearest-airbase lookup iterates only the recipient's own coalition's airbases, and the result is cached per coalition.

Changes in v3.3

Robust Coalition Detection

Change Description
Remove prefix-based routing Zone names (RT* / BT*) no longer determine alert recipient; target:getCoalition() is used instead
Dynamic location strings build_zone_loc called at alert-time instead of pre-cached at startup, so units/NM vs KM match the actual recipient
German alert text for Red Blue receives English "Friendly forces under attack!"; Red receives German "Freundliche Kräfte unter Beschuss!"
Multi-coalition zone support Units from either coalition inside any zone now alert their actual owner

Configuration

ZAA.config = {
    zone_names = {
        "RT1", "RT2", "RT3", "RT4", "RT5",
        "BT1", "BT2", "BT3", "BT4", "BT5"
    },
    message_cooldown = 180,   -- seconds per zone per coalition
    message_duration   = 18,  -- seconds on screen
    debug              = false, -- enable DCS.log diagnostics
    show_startup_message       = false, -- show "ZAA Running" to players
    prefer_own_coalition_airbase = true, -- reference an airbase of the recipient's side
}

Zone prefixes:

Note: Prefixes are for organisational convenience only. Alert routing is determined by target:getCoalition() at runtime, so a Blue unit inside an RT* zone will alert Blue, and a Red unit inside a BT* zone will alert Red.

Debug Logging

Set debug = true in the config table to emit detailed diagnostics to the DCS log file. When enabled the script logs every step of event processing, including zone cache initialization, hit-event receipt, target validation, distance checks against each zone, cooldown state, the detected target coalition, and the final alert dispatch. When disabled, none of the diagnostic strings are built, so the hot path stays free of debug overhead.

Script Architecture

ZoneAttackAlert.lua
├── RELOAD GUARD (skip if ZAA.loaded already set; remove stale handler)
├── ZAA CONFIGURATION (table, user-editable)
├── GLOBAL LOCALIZATION (captured locals for hot path)
├── STATIC DATA (cardinal_directions)
├── ZONE CACHE (built at load time; geometry + bounding box, no coalition)
├── AIRBASE CACHE (built at load time; grouped by coalition)
├── build_zone_loc() (runtime location formatter, NM for Blue / KM for Red)
│   └── Compass bearing: atan2(East, North) using DCS axes (+x=N, +z=E)
├── STATE (last_alert_time table, keyed zone + coalition)
└── EVENT HANDLER (onEvent -> S_EVENT_HIT only, stored in ZAA.event_handler)

DCS Coordinate System Reference

ZoneAttackAlert uses the DCS world coordinate system for all bearing and distance calculations:

The compass bearing from North is computed with the standard formula:

local north_offset = px - airbase.x   -- positive when target is North
local east_offset  = pz - airbase.z   -- positive when target is East
local bearing_deg  = math.deg(math.atan2(east_offset, north_offset)) % 360

This follows the DCS scripting engine FAQ definition (atan2(East, North)) and requires no additional rotational offsets. The result is a clockwise bearing from North in the range [0, 360).

Testing

zaa_test.lua runs the real script against a mock DCS API (world, trigger, Unit, coalition, env, timer) and asserts its behaviour — no DCS installation required.

cd ZAA
lua zaa_test.lua

The script under test defaults to ZoneAttackAlert.lua next to the harness; point it elsewhere with the ZAA_SCRIPT environment variable:

ZAA_SCRIPT=/path/to/ZoneAttackAlert.lua lua zaa_test.lua

The harness covers the unit/static-object filter, the per-coalition cooldown, overlapping zones, hit-position callouts, coalition-aware airbase selection, hot-reload behaviour, and the defensive nil handling. It runs on Lua 5.1 (DCS) and 5.4.

Installation

  1. Copy ZoneAttackAlert.lua to your DCS mission's trigger DO SCRIPT action or load it via require in your mission scripting
  2. Ensure trigger zones named RT1–RT5 and BT1–BT5 exist in your mission
  3. Run the mission; alerts appear automatically when criteria are met

Dependencies

Version History

Version Changes
v1.x Original: camelCase, runtime API calls, all unit types, no pcall, distance printed "0 NM/KM"
v2.0 Optimized caches, aircraft/ground-only filtering, snake_case, pcall guards, < 1 distance fix, coalition-split zones
v2.1 Removed redundant isExist() and getCategory() checks; namespaced config/state into ZAA table
v2.1.1 Heartbeat option added (heartbeatEnabled, heartbeatInterval)
v2.3 Per-coalition cooldown; pre-built zone callouts; removed dead get_location_string function
v2.4 Per-zone cooldown (replaces per-coalition); cooldown check moved into zone scan loop
v2.5 Overlapping zone scan fix; heartbeat timer drift fix; deduplicated zone cache build
v2.6 Reload guard; cardinal direction fix; getDesc() nil guard; removed dedup registry
v2.6.1 Simplified event chain; overlapping zone scan fix; slimmed handler
v2.7 S_EVENT_DEAD fallback; generic "enemy forces" alerts; unified find_zone/send_alert path
v3.0 Radical simplification: S_EVENT_HIT only, no friendly-fire filter, ground+ship targets, single zone cache, single callout per zone, no heartbeat, 50% fewer lines
v3.1 Defensive coding fix: getDesc() guarded against nil (event.target can be destroyed before getDesc completes); ensures S_EVENT_HIT handler never crashes on deleted/damaged units
v3.2 Debug diagnostics: togglable config.debug boolean; structured [ZAA] prefix logging to DCS.log for zone cache, hit events, target validation, zone distance checks, cooldown state, and alert dispatch; zero-cost when disabled
v3.3 Robust coalition detection: alert routing now uses target:getCoalition() at runtime; removed prefix-based .coal field from zone cache; German alert text + KM units for Red coalition; English + NM units for Blue; dynamic location string built per-alert
v3.3.1 Localized cardinal directions: translated cardinal directions into German for the Red coalition (e.g., "nordöstlich") to prevent mixed-language alert messages.
v3.4.0 Correctness: units only (static objects no longer misread as ground units/ships via the StaticObject.Category / Unit.Category numeric collision); per-zone per-coalition cooldown; overlapping zones produce one alert and stamp every containing zone; callouts built from the hit position; stale handler removed on hot reload; neutral units ignored; missing zones warned at startup. Performance: event-id check moved outside the pcall; bounding-box rejection before any DCS API call; getPoint() instead of getPosition(); no descriptor table on the hot path (getCategoryEx with getDesc fallback); debug strings only built when config.debug is on; airbases grouped by coalition.