SkyHub turns a skysim building strike into a vehicle- and mission-specific safety report before the route reaches a real aircraft.
Fly hundreds of drones in one shared physics world — on a single CPU core.
A headless multi-vehicle flight simulator for ArduPilot SITL, built on
Jolt Physics. Gazebo's job, minus Gazebo.
skysim is a drone swarm simulator: a headless physics server that many unmodified
arducopter processes connect to over UDP, so an entire fleet flies in one shared world
where the aircraft can actually see and hit each other — and hit buildings.
It exists because the usual answer, Gazebo, is heavy for this shape of problem. skysim is scoped to one job: many quadcopters over a large scanned-city mesh, with runtime spawn/despawn, lockstep determinism when you want reproducibility, and wall-clock realtime when operators are in the loop.
Vehicles are real, unmodified ArduPilot binaries speaking plain MAVLink, so a ground control station — SkyHub, Mission Planner, QGroundControl — connects to the simulated fleet exactly as it would to real aircraft.
| 🌍 One world, many vehicles | Every drone shares a single Jolt world, so drone-vs-drone and drone-vs-building collisions are real physics, not scripted events |
| ⚡ ~200 quads @ 800 Hz | On a single physics core, with ~5× realtime headroom |
| 🎯 Deterministic when you need it | Strict lockstep mode: same inputs → byte-identical truth logs. Interactive mode ticks the wall clock and freezes stragglers instead of stalling the fleet |
| 🏙️ Real city geometry | Building meshes cooked offline into Jolt shapes, streamed in and out by vehicle proximity under a hard residency cap |
| 🔌 Unmodified ArduPilot | No forks, no patches — arducopter --model json and plain MAVLink |
| 🧩 Runtime spawn/despawn | REST control plane; add and remove aircraft while the world runs |
| 🪶 Headless & cheap | No renderer, no ROS, no Gazebo. One binary |
Each arducopter outsources physics over UDP: it sends a servo packet (16 PWM values) to
9002 + 10·I and blocks until skysim replies. skysim turns PWM into motor thrust (spin-up
lag, X-quad mixer verified against the pinned ArduPilot source), steps one shared Jolt world
by a fixed dt, and replies with the truth state — IMU specific force, position, velocity,
attitude, optional rangefinder raycasts — as one JSON line whose timestamp is ArduPilot's
clock.
┌──────────────┐ servo PWM ──▶ ┌─────────────────────────────┐
│ arducopter 0 │ ◀── truth JSON │ skysim │
├──────────────┤ │ │ ◀── REST :8642
│ arducopter 1 │ ◀────────────▶ │ one Jolt world, fixed dt │ spawn / despawn
├──────────────┤ │ streamed city tiles │ crash counters
│ ... │ ◀────────────▶ │ │ metrics
└──────────────┘ └─────────────────────────────┘
│ MAVLink
▼
SkyHub / QGroundControl / Mission Planner
Read in depth: docs/PROTOCOL.md → docs/DESIGN.md →
docs/SKYHUB_INTEGRATION.md.
Prerequisites — Ubuntu 22.04/24.04, CMake ≥ 3.24, Ninja, GCC 12+ or Clang 16+, and an ArduPilot checkout built for SITL:
git clone --recurse-submodules https://github.com/ArduPilot/ardupilot ~/ardupilot
cd ~/ardupilot && git checkout Copter-4.7.0 && git submodule update --init --recursive
./waf configure --board sitl && ./waf copter
export ARDUPILOT_ROOT=~/ardupilotAlso: pip install pymavlink (harnesses), gcovr (coverage), trimesh (real-mesh pre-step).
Build and test — the test suite needs no SITL:
cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=RelWithDebInfo
cmake --build build -j
ctest --test-dir build --output-on-failure # all unit, happy-path, and performance suites
ctest --test-dir build -L unit # fast C++ correctness tests
ctest --test-dir build -L happy-path # real binary + lifecycle smoke tests
ctest --test-dir build -L performance # 800 Hz and reply-path performance budgets
tools/coverage.sh # HTML/XML/JSON; gates lines, branches, functionsPull requests into main must pass GCC and Clang unit tests, simulator happy paths,
performance budgets, and full src/ coverage. The stable CI required check combines
those jobs so branch protection can block a merge if any one fails.
Fly two drones over a city:
# cook a demo map once
python3 tools/cooker/pretile.py build/demo_map_obj
./build/tile_cooker build/demo_map/tiles build/demo_map_obj/*.obj
./build/skysim \
--vehicles 2 --base-instance 1 \ # endpoints on udp 9012, 9022
--time-mode interactive \ # or: strict (CI/replays; barrier + abort)
--tiles build/demo_map/tiles \ # streamed: --stream-radius / --stream-max
--rangefinders 2 \ # rng_1 down, rng_2 right
--wind 4,1,0 --gust 1.5,2 --seed 42 \ # steady + seeded OU gusts
--api-port 8642 # REST control plane
# then, per vehicle:
$ARDUPILOT_ROOT/build/sitl/bin/arducopter --model json:127.0.0.1 -I 1 \
--home 42.1403890,24.7645490,0,0 \
--defaults $ARDUPILOT_ROOT/Tools/autotest/default_params/copter.parm,tools/harness/params/skysim.parmtools/cooker/osm_buildings.py pulls real footprints and heights from
OpenStreetMap (ODbL) and extrudes them into collision
geometry, georeferenced to a WGS84 anchor so the simulated city lines up with the map your
operators see:
python3 tools/cooker/osm_buildings.py build/city_obj \
--lat 42.1403890 --lon 24.7645490 --radius 5000
./build/tile_cooker build/city/tiles --anchor 42.1403890,24.7645490,0 build/city_obj/*.objThat produces ~19 000 buildings over a 5 km radius — a real city your fleet can crash into.
docker build -t skysim .
docker run --rm -p 8642:8642 -p 9002-9202:9002-9202/udp \
-v $(pwd)/build/city/tiles:/world:ro -e SKYSIM_TILES=/world skysim| Endpoint | Effect |
|---|---|
POST /vehicles {"instance":N, "launch_process":true} (both fields optional) |
reserve a requested or next-free ArduPilot instance (optionally forks arducopter) → {id, instance, json_port, mavlink_tcp} |
DELETE /vehicles/{id} |
despawn, release instance, kill managed process |
GET /vehicles |
per-vehicle: connected, frozen, held_ticks, pos_ned, midair_collisions, building_contacts |
GET /metrics |
tick p50/p99 µs, straggler_events, freezes, resident_tiles |
POST /mission/check {"waypoints_ned":[[n,e,d],...], "clearance_m":1.0} |
sweep a planned route through loaded building geometry → {checked, clear, hits} |
All mutations execute at tick boundaries; reads come from per-tick snapshots. The SkyHub
gateway polls GET /vehicles and, on a collision-counter increase, emits a crash alert to
the dashboard.
| Flag | What it does |
|---|---|
--truth-log out.csv |
Ground truth per tick — judge the physics with this, not the EKF |
--record-servo / --replay-servo |
Deterministic input tapes for reproducible runs |
--time-mode strict |
Lockstep barrier on every vehicle; aborts on a straggler (CI) |
--stream-radius / --stream-max |
City tile residency around each vehicle |
--physics-threads |
Opt a single huge world into Jolt's thread pool (only worth it past ~1000 bodies) |
--io-threads |
Fan the reply path across cores (helps past ~48 vehicles) |
--canned |
M1 protocol-debug mode |
Live acceptance harnesses (need ARDUPILOT_ROOT; use --base-instance 1+ if something
already owns the instance-0 SITL ports):
python3 tools/harness/conformance.py --base-instance 1 [--vehicles 10] # mission gate
python3 tools/harness/churn.py --sim ./build/skysim # spawn/despawn 100×
PYTHONPATH=tools/harness python3 tools/harness/determinism.py --base-instance 1
PYTHONPATH=tools/harness python3 tools/harness/collision_demo.py --base-instance 1One world sustains ~200 quads at 800 Hz on a single physics core with ~5× realtime
headroom; scale past that by sharding worlds across processes. Four optimizations got it
there: a single-threaded physics job system by default (Jolt's thread jitter is a net loss
below ~1000 bodies), an integer fixed-precision JSON formatter (6.3× faster than
snprintf), O(1) contact attribution, and a reply path that fans across --io-threads
cores above 48 vehicles. Measured tick breakdown in docs/DESIGN.md.
The performance suite fails if a 200-vehicle world misses its 1,250 µs tick budget, if the protocol hot path exceeds 2,000 ns per vehicle, or if the pooled 100-vehicle reply path misses the same 1,250 µs budget.
Milestones M0–M6 are complete: protocol layer, single-quad flight conformance, wind /
rangefinders / determinism, multi-vehicle lifecycle, city tiles & streaming, and scale +
SkyHub integration. CI measures every first-party simulator source under src/ and gates
at 85% lines, 70% branches, and 95% functions. Detailed HTML, Cobertura XML, JSON, and
text reports are retained with each Actions run. History and acceptance evidence live in
docs/MILESTONES.md.
Pending human sign-off on the flight model: k_thrust=7.65e-6, linear-only drag, and
SIM_RATE_HZ=800 + --dt 1/800 for conformance (ArduPilot pre-arm requires a gyro rate
≥ 1.8× the loop rate).
src/protocol/ servo packet ABI + state JSON emitter + UDP endpoints (no Jolt)
src/core/ frames.h (NED/FRD <-> Jolt), world.cpp (the ONLY Jolt-aware TU), metrics
src/vehicle/ motor lag + X-quad mixer + instance allocator / process manager
src/terrain/ OBJ -> MeshShape cooker + proximity tile streamer
src/api/ REST control plane (cpp-httplib)
tools/cooker/ pretile.py (demo city) + osm_buildings.py (real city) + tile_cooker CLI
tools/bench/ gated world, protocol, and UDP reply-path performance benchmarks
tools/harness/ conformance / determinism / straggler / churn / collision / corridor
tests/ unit tests + app_smoke.py driving the real binary
Notable interop findings baked into docs/PROTOCOL.md: ArduPilot's
strstr JSON parser rules (field ordering, compact booleans), the two-line handshake
(always reply, even to duplicates), and an upstream Copter-4.7.0 bug where rangefinder data
only flows when euler attitude accompanies the quaternion.
skysim gives SkyHub a shared digital-twin environment for autonomous-drone operations. In the tested local integration, SkyHub-created SITL vehicles keep the normal ArduPilot/MAVLink mission and telemetry path while outsourcing physics to skysim. The gateway reserves each ArduPilot instance through the REST control plane, and every vehicle joins the same georeferenced Jolt world.
That integration provides:
- One fleet, one physical world. Simulated vehicles can collide with one another and with streamed building geometry instead of running as isolated SITL sessions.
- Live operator feedback.
GET /vehiclesreports connection and freeze state, ground-truth position, and separate mid-air and building-collision counters. The SkyHub gateway turns counter increases into collision alerts in the dashboard. - Runtime fleet lifecycle. SkyHub can reserve a specific instance with
POST /vehicles {"instance":N}and remove it without stopping the shared world, keeping its allocator aligned with ArduPilot's port mapping. - Pre-flight geometry checks.
POST /mission/checkcan sweep planned NED mission legs with a configurable approximate clearance envelope through building geometry. It reportschecked:falserather than incorrectly calling a route clear when geometry is unavailable.
The local path has been exercised end to end with two SkyHub-created drones, a physical mid-air collision, and live dashboard alerts. The SkyHub integration notes cover the architecture, test evidence, and the remaining work to package the GCS heartbeat helper for deployment.
A real mid-air collision in a shared skysim world, surfaced live in SkyHub.
Made by ID Robots — the team behind the Observer drone and the NexusBox docking station.
skysim is free software released under the GNU General Public License v3.0 or later (GPL-3.0-or-later) — the same license as ArduPilot, the autopilot it simulates.
See LICENSE for the full text.
You may use, study, modify and redistribute skysim. If you distribute a modified version, or a work derived from it, that work must also be released under the GPL.
Using skysim with proprietary software: skysim runs as its own process and speaks
to the outside world only over the network — a REST control plane and UDP servo/state
packets (see docs/PROTOCOL.md). Software that merely talks to a skysim instance over
those interfaces is a separate program, not a derivative work, and is unaffected by
this license. Linking skysim's source or objects into another program is a different
matter and does place that program under the GPL.

