Beacon system¶
Source: src/CTLD_beacon.lua · Singleton: CTLDBeaconManager · Entity: CTLDBeacon
The beacon system lets a transport crew drop a navigation beacon at its current position (or a mission maker place one at a trigger zone). Every beacon is a triple radio transmitter — one DCS group per band (VHF, UHF, FM) — that broadcasts an audible homing tone for ADF/radio-compass navigation. Beacons run on a battery, are refreshed on a fixed schedule, and can be drawn on the F10 map as a per-player toggleable layer.
There are no separate "beacon types" (no distinct TACAN or IR beacon path in the current
code): every beacon is the same three-TACAN_beacon-group construct described below.
The two classes¶
CTLD_beacon.lua defines an entity and a manager, following the class idiom described in
Architecture:
CTLDBeacon = class() -- one deployed beacon (three DCS groups)
CTLDBeaconManager = class() -- singleton owning all beacons + frequency pools
CTLDBeaconManager._instance = nil
CTLDBeaconManager.getInstance() -- factory, builds freq pools on first access
CTLDBeaconManager:init() builds the frequency pools, schedules the refresh loop (only if
ctld.gs("enabledRadioBeaconDrop") is true), and registers its F10 menu section with
CTLDPlayerManager under the key "beacons" at order = 60.
CTLDBeacon fields¶
A beacon instance carries the three group names, the three assigned frequencies (in Hz), and its battery/lifecycle state:
| Field | Meaning |
|---|---|
beaconName |
Unique key — equals vhfGroupName |
name |
Display name, e.g. "Beacon #3" |
coalitionId |
Owning coalition |
position |
{x, y, z} world position |
vhfGroupName / uhfGroupName / fmGroupName |
The three DCS group names |
vhf / uhf / fm |
Assigned frequencies in Hz |
batteryEndTime |
timer.getTime() deadline, or -1 for infinite |
spawnTime |
timer.getAbsTime() at drop |
isFOB |
FOB beacons are infinite (batteryEndTime == -1) |
Helper methods: isBatteryAlive(), countAliveUnits() (0–3), batteryRemaining()
(math.huge when infinite), freqText() ("245.00 kHz - 350.50 / 45.20 MHz"), and
mgrsCoords().
Radio transmission¶
Each of the three groups is a single TACAN_beacon DCS unit spawned via ctld.utils.dynAdd.
_startTransmissions() sets each group's ROE to WEAPON_HOLD and calls
trigger.action.radioTransmission with band-specific settings:
| Band | Mode | Sound |
|---|---|---|
| VHF | 0 (AM) |
ctld.gs("radioSound") (default beacon.ogg) |
| UHF | 0 (AM) |
ctld.gs("radioSoundFC3") (default beaconsilent.ogg) — silent for FC3 aircraft |
| FM | 1 (FM) |
ctld.gs("radioSound") |
Transmissions are started 1 second after spawn via timer.scheduleFunction: DCS leaves a
freshly-added group's units uninitialised for roughly a second, so calling radioTransmission
immediately would broadcast from a stale or zero position.
Frequency pools¶
Three free/used pool pairs are built once in _buildFreqPools():
| Pool | Range | Step |
|---|---|---|
| VHF | 200–840 kHz, then 850–1250 kHz | 10 kHz below 850, 50 kHz above |
| UHF | 220 MHz up to (not including) 399 MHz | 0.5 MHz |
| FM | 30–75.9 MHz band | (100*f + 10*s + t) * 100 kHz for f=3..7, t=0..9; s=0..9 for f=3..6, s=0..5 for f=7 (keeps the pool at exactly 75.9 MHz) |
Frequencies are stored in Hz. The VHF pool skips a hard-coded set of real-world NDB frequencies
(CTLDBeaconManager._ndbSkip) that already exist on DCS maps, to avoid interference.
_assignFrequencies(granted) draws one frequency per band with _pickFreq(free, used), which picks
a random entry and hands it to _takeFreq(free, used, index) — the one place a frequency leaves the
free pool. When a pool drops to 3 or fewer free entries it recycles the entire used pool back
into free before picking. _freeFrequencies(beacon) returns all three frequencies to their free
pools when a beacon is removed or destroyed.
A caller may ask for a specific frequency instead of taking the draw, via opts.frequencies on
createAtPoint (see
Requesting specific frequencies for the API).
CTLDBeaconManager._bands states each band's option key, unit, Hz multiplier and range, and
_resolveFreqRequest(request) validates the whole request against the pools without mutating
them, returning the granted entries as { [band] = { freq = Hz, index } } or nil, reason. So a
refusal on the third band cannot leave the first two consumed, and a refused call spawns nothing.
The rule is that a request is granted only if the frequency is currently free in its band's pool;
anything else refuses the whole call rather than substituting a random pick. Two reasons, one of each
kind. Behaviourally, a beacon answering on a frequency other than the briefed one is invisible to the
mission maker and inaudible to the pilot who tuned the briefed one — a refusal is at least something
the caller can react to. Mechanically, the pool is the bookkeeping: _freeFrequencies unconditionally
pushes a beacon's three frequencies back into the free pools, so a frequency granted from outside the
pool would be added to it on removal and later drawn at random, and a frequency granted twice would
be freed while the second beacon is still transmitting on it.
Lifecycle¶
dropped → 3 TACAN_beacon groups spawned, transmissions start after 1s
active → _refreshAll() runs every beaconRefreshInterval seconds
destroyed → battery depleted OR fewer than 3 units alive → cleanup + free frequencies
removed → manual removal by a player within 500m
Creation paths. All three go through createAtPoint(point, coalitionId, countryId, opts),
which owns the spawn, the frequency assignment, the battery and the map layers — and nothing that
addresses a pilot. opts carries name, batteryMinutes (-1 = never expires), isFOB and
frequencies (see Frequency pools). It returns the CTLDBeacon, whose
vhf / uhf / fm fields are what a script reads back — or nil and a reason string when the
frequency request is refused or the spawn fails. On that failed-spawn path the three already-drawn
frequencies are returned to their pools, so a caller retrying the same request is not told its own
frequency is in use by a beacon that does not exist.
dropBeacon(transport, player, isFOB, overridePosition)— the transport path: when the aircraft is on the ground it offsets the spawn point behind the aircraft (heading + π, using the bounding-box width plus 5 m, falling back to 20 m) so the ground unit is not spawned inside the aircraft's collision box; airborne, it spawns at the aircraft position. It then announces to the coalition and publishesOnBeaconDropped.createAtZone(zoneName, coalitionStr, batteryLife, name)— the mission-maker path: resolves a DCS trigger zone, maps"red"/"blue"to a coalition and country (RUSSIA/USA), and announces / publishes as a drop by"MissionMaker".createAtPointitself — the scripted path, for a caller that is not a pilot in a cockpit: a script building a FARP or a FOB has no transport and no player. It is silent by design (no coalition message, noOnBeaconDropped— that payload'splayerfield would be meaningless), and it does not honourenabledRadioBeaconDrop, which gates the pilot's F10 action only. The refresh loop, normally started byinit()when that setting is on, is started on demand here so a scripted beacon still transmits and still expires.
Manual removal. removeClosestBeacon(transport, player) finds the nearest same-coalition
beacon within BEACON_REMOVAL_RADIUS (500 m); if none is in range it reports
"No Radio Beacons within 500m.". removeBeacon(name) is its scripted counterpart — display
name or internal key, no distance, no message — returning true when it removed one.
Listing. listBeacons(transport) prints the coalition's active beacons (name + freqText())
to the requesting group.
Battery and refresh¶
Battery life is minutes, read from ctld.gs("deployedBeaconBattery") (default 30). At drop
time a normal beacon's batteryEndTime is set to timer.getTime() + minutes * 60; FOB beacons
(isFOB = true) get -1, meaning infinite.
The refresh loop, scheduled by _scheduleRefresh(), runs _refreshAll() every
ctld.gs("beaconRefreshInterval") seconds (default 60). For each beacon it checks
countAliveUnits() == 3 and isBatteryAlive():
- Both true → re-run
_startTransmissions()(keeps the tone alive across DCS's transmission timeouts). - Otherwise → destroy the beacon's groups, free its frequencies, remove it from map layers, drop
it from
_beacons, and publishOnBeaconDestroyedwithreason="battery_depleted"or"unit_destroyed".
The refresh loop uses a singleton guard: if CTLDBeaconManager._instance is no longer the
instance that scheduled it, the scheduled function returns nil and stops, preventing a zombie
loop after a re-init. The scheduler id is registered under "beacon_refresh" via
ctld.scheduler.register.
Map draw layer¶
The draw layer is a per-player toggle gated by ctld.gs("beaconLayerEnabled").
toggleLayer(player, transport) flips _layerState[player].enabled; when enabled it draws every
same-coalition beacon, when disabled it removes the player's marks.
Each beacon icon is three primitives drawn by _drawBeaconIcon(beacon, markId):
- an outer circle (
beaconIconRadius, default25;beaconIconColor, default orange{1.0, 0.5, 0.0, 1.0}), - an inner solid circle at half radius,
- a text label (
beaconName+ MGRS coords, sizebeaconTextSize, default12).
Mark ID allocation¶
Mark IDs come from the application-wide monotonic counter, not a beacon-local scheme:
ctld.utils.getNextMarkId() -- ctld._markIdCounter starts at 10000, increments and returns
Each beacon icon consumes one id from getNextMarkId() (wrapped locally as _nextMark()),
then derives its three DCS mark ids from it: markId*10 + 1 (outer circle), markId*10 + 2
(inner circle), markId*10 + 3 (text). _removeMarkId(markId) calls trigger.action.removeMark
on all three. The same getNextMarkId() counter is shared with the recon layer and
ctld.utils.drawQuad() (see Recon system); ids are never reused.
When ctld.gs("beaconAutoRefreshLayer") is true, _addBeaconToLayers() pushes a newly-dropped
beacon into every currently-enabled layer, and _removeBeaconFromLayers() strips its marks when
it is removed or destroyed.
Events¶
The beacon manager publishes on the internal event bus (see Events):
| Event | When |
|---|---|
OnBeaconDropped |
A beacon is dropped or created at a zone |
OnBeaconRemoved |
A player manually removes the closest beacon (reason = "manual") |
OnBeaconDestroyed |
Refresh finds the battery depleted or fewer than 3 units alive |
OnBeaconRefreshed |
A refresh tick touched at least one beacon (no-op ticks are silent) |
OnBeaconLayerToggled |
A player toggles the map layer |
F10 menu¶
buildMenuSection(playerObj, menu) builds the Radio Beacons submenu (order 60) under the CTLD
root. It returns early unless playerObj.isTransport, and the whole section is gated by the
enabledRadioBeaconDrop config key (the configKey passed to registerMenuSection). Commands:
- Drop Beacon →
dropBeacon(transport, nil, false) - Remove Closest Beacon →
removeClosestBeacon(transport, nil) - List Beacons →
listBeacons(transport)
Query API¶
| Method | Returns |
|---|---|
getBeaconsForCoalition(coalitionId) |
Array of CTLDBeacon for the coalition |
getBeacon(beaconName) |
The CTLDBeacon for a name, or nil |