EWRS - Early Warning Radar System

Version: v5.3.0

Overview

EWRS.lua is a DCS World Lua mission script that provides automated Early Warning Radar (EWR) picture reports and BRA (Bearing/Range/Altitude) callouts to players based on ground-based radar detection.

Designed for the 4YA Project Overlord WWII Normandy theatre, the script scans designated ground radar units, collects their detected targets, filters for airborne units, categorizes them into geographic zones, and periodically sends formatted reports to active players who have line-of-sight (LOS) to at least one friendly radar.

The system is designed to: - Distribute per-player BRA callouts (automated, via scheduled timers) - Provide a general "picture" overview of enemy concentrations per zone - Scale update rates dynamically based on server population - Only report targets visible above the radio/radar horizon (Earth curvature approximation)


How It Works

1. Script Initialisation

-- All tunables live at the top of EWRS.lua
ewrs.messageUpdateInterval = 240        -- seconds between full message cycles
ewrs.messageDisplayTime = 20             -- seconds each message stays on screen
ewrs.enableRedTeam = true               -- enable for Axis/Red
ewrs.enableBlueTeam = true               -- enable for Allies/Blue
ewrs.useImprovedDetectionLogic = true    -- suppress range for targets without distance lock
ewrs.maxThreatDisplay = 2                -- max threats in BRA table (0 = unlimited)
ewrs.PictureCallouts = true              -- enable zone picture reports
ewrs.NoOfPictures = 2                     -- how many zone summaries to show
ewrs.heartbeatEnabled = false              -- periodic "script is still running" in-game message for debugging
ewrs.heartbeatInterval = 10               -- seconds between heartbeat messages
ewrs.minAltFriendlyContact = 75           -- AGL below which player gets "blind" warning
ewrs.minAltEnemyContact = 10              -- AGL below which enemy contacts are ignored

ewrs.redRadarsByName = { "FREYA", "Freya 1", } `` *initializeZones()` sets up the 23 named polygon zones covering the Normandy map and adjacent Channel areas.

2. Player Discovery

buildActivePlayers() queries net.get_player_list() for all connected players, resolves slots to actual units, and populates activePlayers.

3. Radar Detection (15-second cycle)

pictureUpdate() drives the detection pipeline:

  1. findRadarUnits() scans all group names in blueRadarsByName and redRadarsByName, populating blueEwrUnits and redEwrUnits.
  2. findDetectedTargets(side) iterates each radar unit, pulls getDetectedTargets(RADAR), applies an Earth curvature / horizon filter, and returns a deduplicated list. The geometric horizon is sqrt(2 * R * radar_height) + sqrt(2 * R * target_height), with R = 6,371,000 m. Heights are above sea level (clamped to zero), and range is horizontal. For example, a radar at 1,000 m and a target at 100 m have a combined horizon of approximately 148.6 km. DCS radar detection still determines terrain masking.
  3. filterUnits() performs O(N) hash-map deduplication and filters to air units only (Object.Category.UNIT, descriptor categories 0-1).
  4. A nil guard checks getDetectedTargets(RADAR) before iterating to avoid pairs(nil) errors.
  5. Results are stored in currentlyDetectedRedUnits and currentlyDetectedBlueUnits. Both lists are always replaced, even when a side has no radar units, so a destroyed radar cannot leave stale contacts in the picture report.

4. Zone Aggregation (Picture Report)

picture() runs when a report consumes the picture (see Lazy picture below):

  1. Resets all 23 zone counters to 0.
  2. For each detected target, calls findZone() to determine which zone it falls into.
  3. findZone() first rejects zones using a precomputed bounding box (ewrs.zoneBounds), then runs the full point-in-polygon test only on surviving candidates. The zone boxes are pairwise disjoint, so this eliminates the vast majority of polygon tests.
  4. pointInPolygon() is a local, allocation-free ray-casting test equivalent to mist.pointInPolygon for the {x=, z=} zone vertex format.
  5. Increments the appropriate zone's contact counter.
  6. sortPicture() copies zone data into redPicture / bluePicture and sorts by enemy contact count descending.

The picture is lazy: findRadarUnits() sets ewrs.pictureDirty when detection data changes, and updatePicture() re-aggregates only when a report actually reads the picture. Previously the aggregation ran on a fixed 8-second timer whether or not a report was sent, which wasted most of the work at low player counts.

23 zones cover: LE HAVRE, ROUEN, BERNAY, EVREUX, LAIGLE, ARGENTAN, ESSAY, CABOURG, VILLERS_BOCAGE, BAYEUX, FLERS, ST.LO, AVRANCHES, CARENTAN, CHARTRES, PARIS, BEAUVAIS, CHANNEL WEST, CHANNEL CENTRAL, CHANNEL EAST, ISLE OF WIGHT, SELSEY, BEACHY_HEAD.

5. Threat Table Building (per player)

buildThreatTable(activePlayer):

  1. Gets the player's enemy detected units based on coalition.
  2. For each target, calculates:
  3. Bearing to target (degrees)
  4. Heading from velocity vector
  5. 3D range in km
  6. AGL altitude
  7. If useImprovedDetectionLogic is enabled and the target has no distance lock, Range is set to N/A.
  8. Targets below minAltEnemyContact (10m AGL) are filtered out.
  9. Results are sorted by range ascending (ipairs iteration) and returned.

6. Message Display (round-robin)

displayMessageToAll() runs every N seconds (default 240s, up to 360s at high player counts):

  1. Picks 1 player round-robin from the active player list.
  2. Checks line-of-sight (LOS) to any friendly radar.
  3. If LOS is false, player receives "No radar contacts found".
  4. If messages are enabled in groupSettings, builds the threat table.
  5. outText() formats and sends the BRA report via trigger.action.outTextForGroup.
  6. If player AGL is below minAltFriendlyContact (75m), appends "Kenway/Brutus is blind".
  7. If PictureCallouts is enabled, appends the top zone summaries.

7. Dynamic Interval Adjustment

adjustInterval() runs every 10 minutes:

Player Count Interval
>= 40 360s
>= 30 300s
< 30 240s

This keeps total message volume bounded as server population grows.

8. Reload Safety

All scheduled callbacks are registered through scheduleTracked(), which records the timer id in a per-instance registry (_G.EWRS_TIMER_IDS).


Key Data Stores

Field Purpose
ewrs.blueEwrUnits / ewrs.redEwrUnits List of active radar unit names per side
ewrs.currentlyDetectedRedUnits Detected targets visible to Blue radars
ewrs.currentlyDetectedBlueUnits Detected targets visible to Red radars
ewrs.activePlayers Table of connected players in valid slots
ewrs.bluePicture / ewrs.redPicture Sorted zone summaries for picture reports
ewrs.groupSettings Per-group message toggle state
ewrs.zoneBounds Precomputed per-zone bounding boxes (picture prefilter)
ewrs.pictureDirty Set when detection data changes; drives lazy re-aggregation
ewrs.radarNextPlayer Name of the next player to be messaged (round-robin cursor)

Coordinate System Notes


Logic Flow Diagrams

Overall System Architecture

Game StartsInitialize TablesSchedule pictureUpdate LoopSchedule displayMessageToAll LoopSchedule adjustInterval LoopEvery 15spictureUpdateEvery N secondsdisplayMessageToAllEvery 10 minadjustInterval

Overall System Architecture

pictureUpdate Loop (15s cycle)

pictureUpdatebuildActivePlayersSkip missing data; isolate each player errorSchedule findRadarUnits in 2s(marks the picture stale)Schedule next pictureUpdate in 15s

pictureUpdate Loop (15s cycle)

Radar Detection Pipeline

findRadarUnitsScan blueRadarsByNameScan redRadarsByNamePopulate blueEwrUnitsPopulate redEwrUnitsgetDetectedTargetsfindDetectedTargets redfindDetectedTargets blueFor each radar unitgetController:getDetectedTargets RADARTarget visible?Horizontal range within combined horizon?Radar + target heights above sea levelCollect targetfilterUnitsHash-map deduplicationFilter air units onlyStore currentlyDetectedXUnits YesYes

Radar Detection Pipeline

Threat Table Building (per player)

buildThreatTable activePlayerGet enemy detected unitsGet self positionFor each detected targetTarget exists?Calc bearing to targetCalc heading from velocityCalc 3D range in kmuseImprovedDetectionLogic?Range = N/ARange = calculatedCalc target AGLAlt minAltEnemyContact?Add to threatTableSort by range ascendingReturn threatTable NoYesYes & no distanceNoYesNo

Threat Table Building (per player)

Message Display Pipeline (per player)

displayMessageToAllPlayers connected?Reschedule with default intervalPick 1 player round-robinCheck LOS to any friendly radarLOS true?No radar contacts foundMessages enabled?SkipbuildThreatTableoutTextPlayer AGL 75m?Kenway/Brutus is blindThreats found?Format BRA headerFor each threat up to maxFormat BRG/RNG/ALT/HDG rowPictureCallouts?No nearby targetsSend message to groupAppend zone summariesSchedule next cycle / playerCount NoYesNoYesNoYesYesNoYesNoYesNo

Message Display Pipeline (per player)

Zone Processing (Picture Report)

updatePicture(skip if not stale)Reset all zone counters to 0processTargets redTargets / redContactsprocessTargets blueTargets / blueContactsFor each targetfindZone: bbox prefilterpointInPolygon (local)Inside zone?Increment zone.contactKeyBreak to next targetCheck next zonesortPictureSort redPicture by blueContacts descSort bluePicture by redContacts desc YesNo

Zone Processing (Picture Report)

Player Management

buildActivePlayersQuery net.get_player_listFor each player IDGet name, slot, unitNameUnit.getByNameUnit active?Coalition enabled?addPlayerStore player, groupID, unitname, sidegroupSettings missing?addGroupSettingsDone NoYesYesNoYesNo

Player Management

Interval Adjustment Logic

adjustIntervalCount activePlayers= 40 players?Set interval to 360s= 30 players?Set interval to 300sSet interval to 240sReschedule adjustInterval in 600s YesNoYesNo

Interval Adjustment Logic


Function Reference

Core / Display

Function Purpose
ewrs.displayMessageToAll() Main loop. Picks one player per cycle, checks LOS, calls outText() or sends empty report. Schedules next run.
ewrs.nextActivePlayer() Picks the next player for the round-robin report. Anchored to a player name so roster changes cannot skip a player or waste a cycle.
ewrs.shutdown() Cancels every timer this instance scheduled and clears the loaded flag, so a later dofile() starts clean.
ewrs.buildThreatTable(activePlayer) Returns a range-sorted table of {bearing, range, altitude, heading} for the player's nearest enemy contacts.
ewrs.outText(activePlayer, threatTable, LOS) Formats and sends the BRA picture report (and optional zone summary) to the player's group via trigger.action.outTextForGroup.

Detection & Filtering

Function Purpose
ewrs.getDetectedTargets() Wrapper that triggers detection for both sides.
ewrs.findDetectedTargets(side) Iterates all radar units of side, pulls getDetectedTargets(RADAR), applies horizon/curvature filter, returns deduplicated list.
ewrs.filterUnits(units) O(N) deduplication using a hash map. Also filters to Object.Category.UNIT and aircraft descriptor categories only (0-1).
ewrs.findRadarUnits() Resolves radar group names into active unit lists (blueEwrUnits / redEwrUnits).

Player Management

Function Purpose
ewrs.buildActivePlayers() Queries net API for all connected players, resolves slots to units, populates activePlayers.
ewrs.addPlayer(playerName, groupID, unit) Adds one player record and bootstraps default groupSettings.
ewrs.addGroupSettings(groupID) Initializes per-group settings (includes messages toggle).

Picture / Zone Processing

Function Purpose
ewrs.picture() Resets zone counters, maps all detected targets into zones via findZone(), then calls sortPicture.
ewrs.updatePicture() Re-aggregates the zone picture only when the detection data changed since the last aggregation (lazy picture).
ewrs.findZone(x, z) Returns the index of the zone containing a point, using the bounding-box prefilter before the polygon test.
ewrs.buildZoneBounds() Computes the bounding box of every zone once at startup.
ewrs.sortPicture(zonesData) Copies zone data into redPicture / bluePicture and sorts each by enemy contact count descending.

Timing & Intervals

Function Purpose
ewrs.adjustInterval() Dynamic throttling: increases messageUpdateInterval at 30+ and 40+ player thresholds.
ewrs.pictureUpdate() Master scheduler: runs every 15s, chains buildActivePlayers -> findRadarUnits -> picture.

Helpers

Function Purpose
getCardinalDirection(deg) Converts 0-360 heading to 8-point compass string (N, NE, E, SE, S, SW, W, NW).
getRangeLabel(range) Buckets range into "MAX / LONG / MEDIUM / NEAR / MERGED".
getAltLabel(alt) Buckets altitude into "V HIGH / HIGH / MED-HI / MED-LO / LOW / V LOW".
getDistance3D(...) Euclidean 3D distance.
getBearing(x1, z1, x2, z2) Bearing from (x1,z1) to (x2,z2) in degrees.
getHeadingFromVec(vec) Computes heading from velocity vector.
ewrs.pointInPolygon(px, pz, poly) Local, allocation-free ray-casting point-in-polygon test for the {x=, z=} zone vertex format. Equivalent to mist.pointInPolygon for this format.

Performance Notes


Dependencies


Files

File Purpose
EWRS.lua Cleaned production script (use this one)
old/EWRS_old.lua Original script (kept for reference)
.luacheckrc Luacheck configuration for DCS globals whitelist
README.md This documentation
README.html Rendered HTML with D2 diagrams
../tests/ewrs_test.lua Mock-DCS test harness for EWRS.lua (cd tests && lua ewrs_test.lua)

Version History

Version Date Changes
v4.4 Original Base version by 4YA PO Modifications. Original zone-based picture reporting and BRA callout system.
v4.5 -- Replaced O(N^2) duplicate check in filterUnits with O(N) hash-map implementation. Replaced if/else heading logic with math-based lookup (getCardinalDirection). Inlined math functions (distance3D, bearing) in hot loops as local aliases. Localized global table lookups for performance. Cleaned up redundant code and comments.
v5.0 -- Dead code removal (unused constants, options, variables, mathAbs). Wired up PICTURE_DELAY and PICTURE_UPDATE_DELAY. Changed pairs to ipairs in buildThreatTable. Removed dead groupSettings fields (reference, measurements). Simplified greeting (removed dead "Reference: self" line). Added "No radar contacts found" message when LOS is false. Removed unused variables (side fetch in buildActivePlayers, contactType variable and unitType threat field). Spectator crash fix (slotID nil guard, unitName trimming, radarIndex reset). Nil guard for getDetectedTargets. Added mathCos localization. Added heartbeatEnabled / heartbeatInterval debug options. Added comprehensive README with D2 diagrams.
v5.1 -- Lua 5.1 compliance fix: removed all goto/::continue:: statements (Lua 5.2 feature incompatible with DCS). Replaced with inverted guard conditions in buildThreatTable, buildActivePlayers, and filterUnits. Heartbeat message now sent to all players in-game (was: log only). Added script-load message to players + log ("EWRS Running v5.1", 5s display). Updated AGENTS.md with Lua 5.1 constraint.
v5.2 -- Bug fix: Removed ewrs.radarIndex = 1 reset in buildActivePlayers() (line 609). This reset was clobbering the round-robin index every 15s, causing displayMessageToAll() to always message activePlayers[1] only. With this fix, the round-robin properly advances through all players in activePlayers. Bumped version string.
v5.2.1 -- Coalition messaging: Localized the "blind" altitude warning so Red players receive German ("Brutus hat keinen Sichtkontakt. Erhöhen Sie Ihre Höhe") and Blue players receive English ("Kenway is blind on you. Increase your altitude"). Fixed umlaut encoding (ö, ü, ä) in German strings.
v5.2.2 -- Reload safety: Added a reload guard (_G.ewrs = { loaded = true }) to prevent double-registration of event handlers and duplicate timers on hot-reload (dofile from the DCS console or mission editor). Also bumped the script-load message version string to v5.2.2.
v5.2.3 -- Bug fixes: (1) Fixed double-escaped \\n\\n in the German "blind" altitude warning. The Lua source had \\n\\n (4 source chars: backslash-backslash-n-backslash-backslash-n) which Lua parses to 2 literal backslash-n sequences in the message string. The English version was already correct (\n\n = 2 source chars = 2 newlines when parsed). Red players below 75m AGL were seeing a literal \n at the end of the warning instead of a newline. (2) Fixed inverted heartbeat gating: trigger.action.outText was being called unconditionally on every mission start (even with ewrs.heartbeatEnabled = false, the default), and only the reschedule was gated on the flag. Moved the outText call inside the if ewrs.heartbeatEnabled block. (3) Cleanup: 4 unused loop variable k → _ and 1 whitespace-only line. Bumped the script-load message version string to v5.2.3.
v5.2.4 2026-09-18 Bug fixes: (1) Horizon filtering now uses the combined geometric horizon of radar and target elevations above sea level, compared with horizontal range, so elevated radars retain legitimate detections. (2) Player discovery skips missing names, slots, units, and groups, and isolates each player lookup so stale objects cannot interrupt discovery of other players. Updated detection and player-discovery documentation and diagrams. Script header, startup log, and in-game startup message now report v5.2.4.
v5.3.0 2026-10-22 Bug fixes and performance: (1) Reload safety: all scheduled callbacks are registered through scheduleTracked() into a per-instance registry kept outside the ewrs table. The documented recovery (_G.ewrs = nil then dofile()) now cancels the previous instance's timer chains instead of leaving two copies of EWRS reporting side by side. Added ewrs.shutdown() to cancel every timer and clear the loaded flag. (2) Stale contacts: getDetectedTargets() now always replaces both detection lists, so a destroyed radar can no longer leave its last known contacts in the picture report forever. (3) Round-robin: the cursor is anchored to a player name (ewrs.radarNextPlayer) instead of a list index, so a player joining or leaving the server can no longer make the cycle skip someone or waste a report slot. (4) Performance: the zone point-in-polygon test is now a local, allocation-free ray caster (ewrs.pointInPolygon) instead of mist.pointInPolygon, and each zone carries a precomputed bounding box (ewrs.zoneBounds) used as a cheap prefilter, eliminating the vast majority of polygon tests. This removes the MIST load-order requirement. (5) Lazy picture: zone aggregation is no longer run on a fixed 8s timer; it is recomputed on demand when a report actually consumes it (ewrs.updatePicture()), and the now-unused PICTURE_DELAY constant was removed. Added a mock-DCS test harness (tests/ewrs_test.lua, 22 tests) covering detection, horizon filtering, threat tables, report formatting, reload safety, and the round-robin. Updated detection, zone, picture, message-display and player-management documentation and diagrams. Script header, startup log, and in-game startup message now report v5.3.0.

Installation

  1. Copy EWRS.lua into your DCS mission's "Scripts" folder or "l10n/DEFAULT" folder.
  2. Update ewrs.blueRadarsByName and ewrs.redRadarsByName in the script to match your mission's radar group names.
  3. The 23 default zones are calibrated for the Normandy (N1) map. If using a different theatre, adjust ewrs.Zones polygon coordinates.
  4. Start the mission and check the server log for: EWRS Running v5.3.0
  5. Players with LOS to friendly radar should begin receiving BRA callouts after the first cycle (~15 seconds).