vinsonong.github.io/CUAS — the static
operator console UI only. GitHub Pages can't run the C++ backend, so the page
loads but has nothing to talk to: the sensor/effector/track lists stay empty
and the connection indicator shows disconnected. To see the actual
simulation running, build and run the backend locally — see "Quick start"
below, then open http://localhost:8080 instead.
A counter-UAS (drone defense) command-and-control prototype: multi-sensor fusion, rule-based classification, collateral damage assessment (CDA), simulated effectors, and a browser-based operator console with a 2D map view and a 3D situational view — both drawn over the same real satellite imagery (Esri's free World Imagery, pannable and zoomable in both views, desaturated near-grayscale) centered on the site's actual configured coordinates. The visual language — monochrome map, sparse deliberate color, dense compact panels, small reticle-style contact markers, dashed motion trails — follows DroneShield's real DroneSentry-C2 C-UAS console rather than an invented "tactical" look.
The operator deploys sensors and effectors themselves: pick a type from the
"+ Sensor" / "+ Effector" / "+ Drone" dropdown above either view and click
the map (2D, pan/zoom via Leaflet) or the ground (3D, via raycast — drag to
orbit, scroll to zoom, right-drag to pan) to place it there. Five sensor
types (radar, EO/IR, RF direction finder, acoustic, LIDAR) and five effector
types (RF jammer, net capture, kinetic interceptor, directed-energy laser,
GNSS spoofer) are built in — plus a Custom… option on both dropdowns
that opens a form for a fully operator-parameterized unit (range, FOV,
footprint, collateral potential, ...) instead of writing a new C++ class.
Any number of each, anywhere. Both views read the same World state, so a
unit placed in one appears — at the same coordinates, with its own coverage
ring or FOV wedge — in the other, as a small reticle-style marker rather
than a map-pin teardrop (see .pin in web/styles.css). Every sensor and
effector type gets its own glyph — a dish for radar, a lens for EO/IR, a
missile silhouette for a kinetic interceptor, and so on (TYPE_ICON in
web/app.js) — shown on the map pin, in the sidebar row, and nowhere else
needs to know about it; sensors and effectors still read apart at a glance
by badge shape and color alone (circle/blue vs. rounded-square/amber) even
before the icon registers. A selected track gets a pulsing target-lock ring,
and every track drags a short dashed trail behind it showing its recent
path — both modeled on DroneSentry-C2's own operator view.
Every sensor, effector, and drone can be renamed, reconfigured, or removed after the fact — the ✎/✕ icons on each row in the Sensors/ Effectors/Tracks lists open a configuration form or a remove confirmation. For a drone specifically that also means changing its flight profile (waypoints in-and-out / loiter in a circle / wander randomly) and speed live, or spawning a new one directly as a bird, commercial drone, or FPV drone with a chosen speed and profile via "+ Drone" → Custom….
The map is the whole canvas — full width and height of the window, not squeezed into a center column — with two translucent, blurred panels floating over it (again, DroneSentry-C2's own layout): left is what you've deployed (Sensors, Effectors — each independently scrollable); right is the threat picture and the decision (Tracks, Engagement Assessment, Audit Log — likewise each scrollable on its own, so a long track list never pushes the assessment panel out of view). Below 900px width this collapses to a normal stacked single-column page instead — floating overlays over a full-bleed map are a desktop pattern that doesn't work at phone sizes. The 2D map also has a My location button next to its zoom control, using the browser's own Geolocation API to center the map on the operator's device (with a pulsing marker of its own) — purely a browsing convenience, unrelated to the simulated sensor picture.
This is a simulation. No real sensors or effectors are connected — everything from radar contacts to jammer engagements is synthetic, run entirely on your machine. It exists to demonstrate the architecture a real system would need, with the seams marked where real hardware would plug in. See "Known simplifications" and "From simulation to real hardware" below before mistaking this for anything closer to operational.
Every design choice here follows from one constraint: a human operator must be the only thing that can ever pull the trigger. Concretely:
Effector::engage()is only ever called fromEngagementController::confirm, which itself only runs when aPOST /api/command {"action":"confirm_engagement"}arrives from the operator UI — there is no path from sensor detection to effector action that doesn't go through an explicit UI confirmation.- Every assessment and every engagement attempt (allowed, denied, successful,
or failed) is written to an append-only audit log
(
EngagementController::auditLog()), visible live in the UI, so every decision is attributable and reviewable after the fact. - Collateral Damage Assessment is advisory but load-bearing: a hard no-fire zone (e.g. a hospital) blocks engagement regardless of score, and the CDA score gates the UI's confirm button.
# Configure + build (needs CMake ≥ 3.16 and a C++17 compiler)
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build
# Run from the project root (paths default to config/ and web/ relative to cwd)
./build/cuas_server
# then open http://localhost:8080No CMake on hand? It's three modules and a main, so a direct compile works too:
clang++ -std=c++17 -O2 -Isrc -o cuas_server \
src/main.cpp src/world/TruthWorld.cpp src/sensors/SimulatedSensors.cpp \
src/fusion/FusionEngine.cpp src/classification/Classifier.cpp \
src/cda/CdaEngine.cpp src/effectors/SimulatedEffectors.cpp \
src/engagement/EngagementController.cpp src/world/World.cpp src/net/HttpServer.cpp
./cuas_serverOptional args: ./cuas_server <config.json> <webRoot> <port> (defaults:
config/site_config.json, web, 8080).
The UI needs network access for two CDN dependencies — three.js (3D view) and Leaflet plus the Esri map tiles (background imagery in both views). All track/sensor/effector data and every engagement action (assess, confirm, audit log) work with zero network; only the basemap imagery is missing without it, and each view falls back to a plain message in its place rather than breaking.
No build system, browser, or headless driver is bundled in this repo — building and opening the page is on you or your CI.
TruthWorld (simulated ground truth, src/world/TruthWorld.*)
│ observed imperfectly by
▼
Sensor implementations (src/sensors/) ── radar, EO/IR, RF direction-finder
│ raw Detections
▼
FusionEngine (src/fusion/) ── nearest-neighbor association + per-axis Kalman filters
│ fused Tracks
▼
Classifier (src/classification/) ── rule-based class + threat score
│
▼
CdaEngine (src/cda/) ── scores a candidate engagement against RiskZones
│
▼
EngagementController (src/engagement/) ── ROE gate, audit log, calls Effector
│
▼
Effector implementations (src/effectors/) ── RF jammer, net capture, kinetic interceptor
World (src/world/World.*) owns one instance of everything above, runs the
simulation tick loop, and is the thread-safety boundary between the
simulation thread and the HTTP server's worker threads (one mutex, locked for
tick(), snapshot(), and handleCommand()).
HttpServer (src/net/HttpServer.*) is a minimal hand-rolled HTTP/1.1
server — no external dependencies — serving the static web/ UI plus:
| Endpoint | Method | Purpose |
|---|---|---|
/api/stream |
GET | Server-Sent Events; one JSON world snapshot per tick (~4 Hz), including sensors[] and effectors[] with positions |
/api/state |
GET | One-shot JSON snapshot (handy for curl/debugging) |
/api/command |
POST | {"action":"assess"|"confirm_engagement", "trackId", "effectorId", "operatorId"?} — engagement flow |
/api/command |
POST | {"action":"add_sensor", "sensorType", "x", "y", "z"?, ...} — sensorType ∈ radar|eo_ir|rf_df|acoustic|lidar|custom; custom also takes label, rangeM, fovDeg, requiresRf |
/api/command |
POST | {"action":"remove_sensor", "sensorId"} / {"action":"configure_sensor", "sensorId", "label"?, "rangeM"?, "fovDeg"?, "boresightDeg"?} |
/api/command |
POST | {"action":"add_effector", "effectorType", "x", "y", "z"?, ...} — effectorType ∈ rf_jammer|net_capture|kinetic_interceptor|directed_energy|gnss_spoofer|custom; custom also takes label, rangeM, footprintRadiusM, collateralPotential, isKinetic, closingSpeedMps, engagementDurationSec, cooldownSec, successProbability |
/api/command |
POST | {"action":"remove_effector", "effectorId"} / {"action":"configure_effector", "effectorId", "label"?, "rangeM"?, ...} |
/api/command |
POST | {"action":"add_drone", "droneClass":"bird|drone_commercial|drone_fpv", "x", "y", "z"?, "speedMps"?, "profile"?:"waypoints|loiter|wander", "label"?} |
/api/command |
POST | {"action":"remove_drone"|"rename_drone"|"configure_drone", "trackId", ...} |
web/ is a vanilla HTML/CSS/JS console (no framework, no build step):
app.js connects to /api/stream, renders the track/sensor/effector lists,
drives a Leaflet map for the 2D view, lazily builds a three.js scene for the
3D view (texturing its ground plane with the same map tiles Leaflet uses),
and posts operator commands. enuToLatLng/latLngToEnu in app.js mirror
GeoRef in src/core/Vector3.hpp exactly, so a click anywhere on either map
converts back to the same local-ENU meters the backend gates range/detection
against.
This codebase has exactly two abstract interfaces meant to be swapped for real integrations without touching anything downstream:
Sensor(src/sensors/Sensor.hpp) —poll()returnsDetections;position()/rangeM()/fovDeg()/boresightDeg()are what the UI draws as that sensor's coverage plot, and are all mutable (setLabel/setRangeM/setFovDeg/setBoresightDeg) soconfigure_sensorcan adjust a deployed unit live.SimulatedRadar/EOIR/RF/Acoustic/Lidar(src/sensors/SimulatedSensors.*) implement it againstTruthWorld; a real integration implements it against an actual radar feed, gimbal SDK, or RF direction-finder API instead.ConfigurableSensoris the sixth, operator-defined type (sensorType: "custom") — a generic range/FOV/ RF-dependence detection model instead of a named class, for when the UI's "Custom…" option is used rather than writing new C++. Every sensor is operator-placed (World::addSensor) rather than fixed at the site origin.Effector(src/effectors/Effector.hpp) —engage()performs the physical action;position()is where range/time-of-flight are measured from;setName/setRangeMbackconfigure_effector. The five simulated effectors (src/effectors/SimulatedEffectors.*) implement it with a success-probability dice roll; a real integration implements it against the vendor's actual control API.ConfigurableEffectoris the sixth, operator-defined type (effectorType: "custom") — its footprint (groundRadiusM/collateralPotential/isKinetic) is a stored, mutable triple rather than a hardcoded per-class return value, which is what letsconfigure_effectorchange it after placement. Also operator-placed (World::addEffector).
FusionEngine, Classifier, CdaEngine, EngagementController, World,
and HttpServer never need to change for a real integration — they only
know about the Sensor/Effector interfaces.
Unlike sensors/effectors, "drones" have no adapter seam — TruthWorld
(src/world/TruthWorld.*) is the simulation, not something a real
deployment swaps out (a real system has no ground truth to manage, only
what its sensors report). An operator can still fully script it though:
add_drone spawns a SimTarget with a chosen class, speed, and
FlightProfile (waypoints in-and-out of the site / loiter in a circle /
wander randomly, independent of what class it looks like); configure_drone
changes speed/profile on an already-spawned one; remove_drone despawns it.
These coexist with the automatic ambient spawner (TruthWorld::spawnIncursion)
rather than replacing it — operator-added targets are flagged
operatorAdded and excluded from its concurrency cap and never auto-pruned.
Renaming a track (rename_drone) does not touch its id — fusion
association and the audit log key off id, which never changes; the
operator-facing name lives in a separate Track::displayLabel (empty =
show id), exactly so a rename can never collide with or destabilize the
fusion/audit-trail bookkeeping.
Documented here rather than hidden, since this is a prototype whose honesty about its limits matters more than looking finished:
- Single-hypothesis nearest-neighbor fusion, not JPDA/MHT.
FusionEnginegates each detection to the nearest track within a per-sensor radius; there's no multi-hypothesis tracking. A cheap same-tick dedup pass (kMergeRadiusMinFusionEngine.cpp) collapses the worst duplicate-track artifacts this causes, but it's a mitigation, not a fix — dense or crossing tracks will still confuse it. - RF direction-finding is modeled as a noisy position sensor, not true bearing-only triangulation. A real DF sensor gives you a bearing, not a fix; multi-baseline triangulation (or fusing with radar range) is a real extension, not implemented here.
- Engagement resolution is instantaneous.
EngagementController::confirmrolls a success probability and resolves immediately rather than modeling an interceptor's flight-out over multiple ticks. - CDA is a simplified radius/density model, not a real GIS population
layer or a physics-based fragmentation model.
RiskZoneandEngagementFootprintinsrc/cda/are the extension points for either. - Flat-earth local ENU geometry (
GeoRefinsrc/core/Vector3.hpp), fine at the few-kilometer scale a site defense scenario cares about, not a real geodetic (ECEF/UTM) transform. - One mutex for the whole
World. Correct and simple at this scale (a handful of tracks/effectors, ~4 Hz tick); a busier system would need finer-grained locking or a different concurrency model. - The HTTP server is deliberately minimal: one thread per connection, no keep-alive/pipelining, generous but hard size caps on headers/bodies. It's sized for a handful of local UI clients, not internet-facing traffic.
- The basemap shows real streets at the site's configured coordinates,
which is a real place (
config/site_config.json'srefLatDeg/refLonDeg) even though "Fictional Protected Site" and its risk zones are invented. That's intentional — a fabricated satellite image would be actively misleading, and real terrain under a fictional scenario is a standard training-sim technique — but it means the risk-zone names and the streets underneath them don't correspond to anything real; don't read significance into what's actually at those coordinates. - Map tiles come from Esri's ArcGIS Online "World Imagery" (satellite)
and "Reference/World_Boundaries_and_Places" (labels) services
(
server.arcgisonline.com, no API key), not Google Maps. Google's Maps JavaScript API needs a key tied to a billing-enabled Cloud project, which this repo doesn't have and can't provision; Esri's imagery was chosen because it's free, keyless, and — tested directly — more reliable right now than CARTO's (which started requiring a key) or plain OpenStreetMap tile servers (which block programmatic/app traffic per their usage policy). It's shown desaturated near-grayscale (a CSS filter on.leaflet-tile-pane, andc2d.filterbefore the 3D ground texture is baked) rather than in full color, to match DroneSentry-C2's tactical map treatment. Swapping to Google or another provider means changing theL.tileLayerURL ininitLeaflet()and the tile-fetch URL inbuildGroundTexture()(app.js) — both are the only places a tile URL is hardcoded.
- Implement
Sensoragainst your actual radar/EO-IR/RF hardware or API; register it inWorld::World(src/world/World.cpp) alongside or instead of theSimulated*sensors. - Implement
Effectoragainst your actual jammer/interceptor controller; register it the same way. - Replace
RiskZonepopulation data inconfig/site_config.jsonwith a real GIS/population-density source, and revisit the overlap model inCdaEngine::assess(src/cda/CdaEngine.cpp) if you need something more physically grounded than radius-overlap. - Everything else — fusion, classification, the ROE gate, the audit log,
the UI — keeps working unchanged, because it only ever talks to the
Sensor/Effector/Track/RiskZoneabstractions.
CMakeLists.txt
config/site_config.json — site reference point, protection radius, risk zones
src/core/ — Json, Vector3/GeoRef, KalmanFilter, Track
src/world/ — TruthWorld (sim ground truth), World (ties everything together)
src/sensors/ — Sensor interface + simulated radar/EO-IR/RF
src/fusion/ — FusionEngine (association + Kalman filtering)
src/classification/ — Classifier (rule-based class + threat score)
src/cda/ — RiskZone, CdaEngine (collateral damage assessment)
src/effectors/ — Effector interface + simulated jammer/net/interceptor
src/engagement/ — EngagementController (ROE gate + audit log)
src/net/ — HttpServer (static files, SSE, command API)
src/main.cpp — wiring: sim thread + HTTP server
web/ — operator console (index.html, styles.css, app.js)
Built by Vinsonong, with assistance from
Claude Code. Third-party resources used
at runtime, all loaded from their public CDNs — no local copies, no
package.json: Leaflet and
Esri World Imagery
for the 2D map tiles, and three.js for the 3D view.
The site/risk-zone data in config/site_config.json (site name, hospital,
school, etc.) is entirely fictional, invented for this simulation.
No license specified.