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.
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.
Unit.Category.GROUND_UNIT and Unit.Category.SHIP doALERT: Friendly forces under attack!); German for Red (ALARM: Freundliche Kräfte unter Beschuss!)config.debug to emit [ZAA] prefixed diagnostics to DCS.log| 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. |
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. |
| 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 |
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:
RT* — Red Team trigger zonesBT* — Blue Team trigger zonesNote: Prefixes are for organisational convenience only. Alert routing is determined by
target:getCoalition()at runtime, so a Blue unit inside anRT*zone will alert Blue, and a Red unit inside aBT*zone will alert Red.
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.
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)
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).
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.
ZoneAttackAlert.lua to your DCS mission's trigger DO SCRIPT action or load it via require in your mission scriptingRT1–RT5 and BT1–BT5 exist in your missionworld.addEventHandler)| 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. |