I had the idea to simulate a Betaflight flight controller for some time and, in 2023, I began porting the code to C#. However, I soon discovered that Betaflight already had a target that does exactly this! The SITL (Software in the Loop) target can be built on a Linux machine and even runs smoothly in WSL (Windows Subsystem for Linux), which is fantastic. However, this setup presents several challenges for the average Windows user.
First, WSL as a dependency complicates the setup. Installing WSL can be a significant hurdle for inexperienced users, and to make matters worse, the host IP address tends to change after each restart. While this issue can be addressed with scripts and IP queries, I wanted to avoid that level of complexity. Diagnosing such problems remotely, especially for non-tech-savvy users, can be difficult, and it also introduces platform dependency. Don’t believe me? Try setting it up yourself using the official guide: Betaflight SITL Guide.
The second major problem is the ping-pong like synchronous communication over the network stack between the virtual flight controller (SITL) and the simulation. The SITL process waits for updated simulation data (fdmPkt) and then generates motor outputs (pwmPkt) that are sent back to the simulation. The simulation interprets these motor outputs and adjusts the virtual quadcopter’s orientation accordingly. This, in turn, creates new gyro input for the SITL, and the loop repeats. However, this introduces latency and undesirable artifacts in the simulation, such as sudden jumps in RC data, gyro input, or motor outputs. These issues arise when the simulation doesn’t run a perfectly timed, high-frequency update loop, which is virtually impossible. You can check out the code here. It’s extremely simple: it receives inputs and responds with outputs. While this simplicity likely aims to offer flexibility, it also limits how fast updates can be processed. Given that an average quadcopter processes gyro samples at 4kHz, you only have 0.25 ms to run the simulation, including the overhead caused by UDP-based network communication, threading, and the OS scheduler!
And lastly, where are the OSD and blackbox features? Unfortunately, SITL doesn’t include these out of the box.
Fortunately, KwadSim and its counterpart, KwadSimITL, were mentioned on the Betaflight Discord, and they address some of the shortcomings of the Betaflight SITL target. First off, KwadSim comes with a Windows CMake toolchain that uses MinGW, eliminating the need for WSL. That’s a big win—no more WSL dependency!
KwadSimITL also features parameterized physics and allows to run multiple simulation steps with a single input. In other words, a single input packet can generate a finer simulation resolution. This shifts some of the computational load from the game client to the SITL process, as a low-frequency game physics loop can trigger multiple SITL physics updates. For example, the game client’s physics loop might run at 60Hz, but each update could trigger 100 SITL physics updates, providing the PID loop with 6kHz of fresh, unique gyro inputs.
But that’s not all—OSD and blackbox recording are already integrated and fully functional.
However, KwadSimITL isn’t without its flaws. The project hasn’t been updated since early 2020 and is based on Betaflight version 4.1.0, which is quite outdated. But the most significant challenge for me was the game client. KwadSim uses Godot 3.2, a game engine I have no experience with. So, I decided to rewrite the game client from scratch in Unity—and that’s how pr0p was born.
pr0p
This is my attempt at creating a quad copter simulation based on the previous work of KwadSimITL and a few of the biggest problems I tried to solve.
fun with state updates
Updating the simulation state across two processes was a tough challenge, particularly because any added latency in this step was immediately noticeable in the overall flight experience. Since the default SITL target simply responds to requests from the simulation process, it’s important to understand the relationship between these two processes.
It’s worth noting that while the functions used to demonstrate this timing problem may not take long in practice, they help illustrate the issue. Let’s assume the simulation runs at 60Hz, and the “simulate” function of the simulation process generates a new simulation state (fdmPkt) for the SITL process. The SITL process then receives this data, generates motor outputs (pwmPkt), and sends them back to the simulation process. Once the motor outputs are received, the simulation updates its state in the next step, feeding the new information back to the SITL process, and the loop continues.
If the goal is to reduce latency by pushing the update frequency to its limits, the process would look something like this:
The gap between function pairs narrows, and simulations are called more frequently than before. However, the update frequency can only be pushed so far—if it goes too high, the simulation may miss motor inputs. If the simulation runs ahead without waiting for fresh motor outputs, it ends up calculating two packets based on the same motor data.
Why is this behavior undesirable?
In simple terms, the simulation requests new motor output values from the SITL process. SITL’s PID loop calculates these motor outputs, and the simulation uses them to update the quadcopter’s orientation. If the simulation step begins before receiving fresh motor outputs, it calculates the orientation based on outdated data. In the worst case, the new orientation will be the same as the previous one, or it may miss a critical change that would have occurred with the updated motor outputs. When this stale orientation data is fed back into the PID loop, the motor outputs may increase because no orientation change was detected. It’s as if an external force is preventing the quadcopter from responding to control inputs for an additional simulation step.
This effect is something most people have experienced in real life: if a quadcopter’s motors are spinning without propellers and it’s armed with RC inputs requesting orientation changes, the motors will keep increasing RPMs. This happens because the flight controller detects no orientation change, even though the motors are spinning faster and faster, so it continues to push harder.
To summarize, SITL needs input that reflects the previous output—meaning the orientation must respond to motor outputs with every round trip. If this isn’t guaranteed, motor outputs may become higher than expected, either for single or multiple update steps.
KwadSim(ITL) has a nice concept for this problem. It introduced 2 simulations, one for each process. The SITL process has it’s own physics simulation, the same that the game client has and both run more or less independent fom each other, but include updates from each other. The game client receives the orientation and acceleration updates, calculated from SITL physics updates, and the game client sends its version of these values back.
This concept is modified slightly for pr0p and its SITL fork, SimITL. The game client (pr0p) handles the complex task of calculating collisions between the quadcopter and the environment, which can be CPU-intensive. SimITL, on the other hand, focuses solely on quadcopter physics—handling the motors, propellers, frame, and overall behavior as if the quadcopter were flying in free space without obstacles or collisions. This makes SimITL’s task simpler and less CPU-intensive per update step, allowing it to run at a much higher frequency—currently around 3.2kHz—to generate realistic motor noise. The game client, meanwhile, skips these detailed flight dynamics calculations and simply incorporates the acceleration updates from the SimITL process whenever they are received.
This diagram illustrates pr0p’s simulation updates, which are executed almost simultaneously at a single time point (7 state packets in the first line) due to Unity’s physics simulation running in a simple loop with constant time deltas. Thanks to the rapid response from SITL (first packet in the second line), pr0p is able to incorporate this new data into one of its subsequent state updates. The time scale is in microseconds, and the events were recorded using Wireshark. Notably, the SITL process responds incredibly quickly to pr0p’s initial state update—within approximately 40 microseconds in this specific time window. This near-instant response is achieved by using separate threads for networking on both sides, which actively spin to immediately respond to incoming data.
The beauty of this approach lies in its speed, even when operating over the network stack. Both processes integrate new packets immediately upon receipt, while SITL fills in the gaps by calculating finer steps between packets to generate more realistic motor and propeller noise. In the event of a collision, pr0p uses its own physics engine to handle the majority of the calculations, blending them with SITL’s orientation data. This combined result is then fed back to SITL, allowing the flight controller to respond accurately to collisions.
Of course, this entire process is a simplified depiction. Input queues can discard packets if too many are received before processing, which may lead to sudden jumps in orientation and cause the PID loop to react harshly. Additionally, the operating system’s scheduler can delay the execution of a process, potentially preventing an immediate response from SITL. Despite these challenges, the system performs well overall, delivering a convincing flight experience with impressive responsiveness and reasonable CPU utilization.
Up next: Unity’s input system and its flaws…
In the meantime, try pr0p: https://pr0p.dev/download