Bits and Droids logo

Miniature rudder pedals

by Bits and Droids | 21-03-2023

With the release of connector version, I've introduced new ways to calibrate your rudder pedals, yokes, and brakes. My main setup uses the Thrustmaster TFRP pedal set ( They are a decent starting point for anyone looking to get a "cheap" (relative to similar products) rudder set. The downside is that I didn't have any custom-made rudder pedals to test whether or not the rudder changes were working.

That is where this project enters the picture. A miniature set that you can control using two fingers. Will it replace my Thrustmaster set? Probably not in the foreseeable future. But it can teach you how to create, code, and calibrate your custom rudder pedals on a larger scale.

What is used in this tutorial

*These are affiliate links that help support the channel.

To illustrate the scale, I've put my rudder sets next to each other. The base is rather bulky due to the component size versus the pedal size. The components won't be noticeable if you replicate real-life rudders on a 1:1 scale.

The design

The design I've ended up with is based on the same principles entry-level rudder manufacturers use. The crossbeams move perpendicular to the side beams (the iron rods). This construction enables us to move one pedal forward while the other moves backward. For the crossbars, I'd recommend using 2.5mm rods. There's a chance you'll need a small iron handsaw to cut the bars to size. The 2.5mm bars are too thick to cut with pliers for me. This has probably more to do with the lack of strength on my part. We connect the crossbars directly to the potentiometer. A design this small leaves us with a small range of movement. But no worries, we'll fix this with the calibration settings.

A simple oversight on my end has been that I didn't limit the sideways motion. This still enables us to rotate our pedals in a radial instead of a linear motion. You can fix this by adding a guide rail to your pedals, forcing the construction to move linearly. I'll provide a simple shroud fixing this issue in an updated iteration.


You can use any Arduino (or other microcontrollers) you'd like for this project. Wiring up the rest will be a breeze. Grab 3 wires (preferably a red, black/brown, another 3rd color). The potentiometer has just 3 pins we need to connect to our controller.

  • Connect the left pin to the 5v/3v pin on your controller (preferably the red wire).
  • Wire the right pin to the ground/GND pin on your controller (prefferably the black/brown).
  • And last but not least connect the middle pin to an analog pin of your choice. I went with A0.

The color of the wires doesn't change the behavior of the cables. A wire will still be a wire. Using red for voltage and black/brown for negative/ground make it easy to spot what each wire does. This coding is also an investment to please future you. If you open up the project in a year it'll become a lot harder to service the device when you didn't follow a color-coding scheme. This is the same color scheme most hobbyists use for small consumer electronics.

If you want to read more about potentiometers go check out our back-to-basics potentiometers article!

The code

I've made sure to make the code you'll need as simple as possible. It's so simple you'll only need to add four lines of code. Is coding always that simple? It depends. In this case, most of the magic happens underneath the hood. To save you some time, I've created a function in the library that handles all the magic so you can focus on other things.

//Include the code found in the BitsAndDroidsFlightConnector library
#include <BitsAndDroidsFlightConnector.h>
//Create a connector object
BitsAndDroidsFlightConnector connector = BitsAndDroidsFlightConnector();

void setup(){
  //Start serial communication
  //The connector defaults to a rate of 115200 (the rate at which data gets transmitted)

void loop(){
  //A0 reffers to the pin your potentiometer is connected to.
  //This could differ if you plugged it into a different hole


Now that we've constructed and coded up our pedals, we are ready to use them ingame. For some of you, this might be an out-of-the-box experience. For others, a bit of tweaking can go a long way. Let's take our small plastic contraption as an example. The moment we place our fingers down, the rudders start moving. You can counteract this movement with a bit of finger wiggle, but in the end, all these tiny changes will be transmitted to the game.

Finding your center

To calibrate our pedals, we'll open the serial monitor to visualize the values it transmits. These are the steps I use to calibrate my rudders:

  • Place the pedals in a neutral position. Enter the monitored value in the Neutral text box.
  • Place the pedals in a full left position. Enter the monitored value in the Min box.
  • Place the pedals in a full right position. Enter the monitored value in the Max box.
  • Press the reversed button if your momevent responds inversed (left is right and right is left).


We've now defined the range that is available to us and where the center is located. To even further fine-tune the inputs to our liking we can change the sliders.

  • Deadzone creates a zone around our neutral value where no changes will be registered. If you slightly press the rudder pedals when putting your feet on the pedals there is a chance you'll move your contraption slightly. To ignore these changes around the neutral zone we can increase the deadzone. This won't affect the readings outside the deadzone. In summary, a small change around the neutral zone can be filtered out while the readings outside this zone get transmitted no matter how small the change.
  • Sensitivity - lets you change the sensitivity on your left pedal input. Here you're able to split the left pedal into two different zones. Want to have finer control on the first part. decrease that slider. Want to have finer controls at your max pedal position. Increase this slider.
  • Sensitivity + lets you change the sensitivity on your right pedal input. Here you're able to split the right pedal into two different zones. Want to have finer control on the first part. decrease that slider. Want to have finer controls at your max pedal position. Increase this slider.

What happens behind the scenes?

Let's dive deeper into the inner workings of the library and connector. Don't worry. You don't have to copy any of the code in this chapter.

There is a reason we've used a potentiometer. By reading the analog signal as a value, we can determine which direction the wiper faces. In theory, closed will always be 0, and open will always be your maximum value (usually 1023). If we place the wiper in the middle, it will be 511, and so on. If we translate this to our ingame rudder, we have a neutral position, a full left position, a full right position, and everything in between. In the end, we want to map the position of our potentiometer to the position of our ingame rudder.


When you take off from the centerline of the runway, you'd want to use your rudders to keep you centered during the entire take-off process. If we mapped the potentiometer directly to our ingame rudder, it wouldn't take long before we threw our rudder out of the window. When we don't touch the component, we can still observe some jitter in the readings (i.e., a sequence of readings could return: 511, 512, 511, 511, 512). We could create an advanced circuit that eliminates some noise, but it's easier to filter out the noise codewise.

An unfiltered approach is simple to implement. We read the value. Pass it to the connector. The connector sends the command to the game, and we're done. This would leave us with a mediocre experience.


To apply the first layer of filtering, the library buffers several readings. It samples ten readings and saves them to an array. When the array is filled, it takes the average of all the values and sends that to the connector.

int BitsAndDroidsFlightConnector::smoothPot(byte potPin) {
    //Samples is defined in the header file. It represents the amount of readings done before averaging
    //A higher value will result in less jitter but more input lag and vice versa.
    //Defaults to 10
    int readings[samples] = {};
    total = 0;
    for (int &reading: readings) {
        total = total - reading;
        reading = analogRead(potPin);
        total = total + reading;
    average = total / samples;
    return average;


Even after averaging out the readings, there will still be jitter on the line. The difference between these readings is 99% of the time, a value of 1. The simplest solution to filter this out will be to check if the change is bigger than one before sending the command to the connector. We use an if statement to compare the old value to the current value and see if the difference is bigger than 1. What happens if we use the following code to filter out jumps bigger than 1?

if(oldValue - newValue > 1){
  //Do something

If we have a movement that decreases the value by 2, we could fill in the blanks to see the result.

//OldValue = 4, newValue = 2
if(4 - 2 > 1){
  //4-2 = 2. 2 is bigger than 1 so the code in this block gets excecuted

We could fill in the same blanks with an increase by 2 to illustrate why this "if" statement won't work.

//OldValue = 2, newValue = 4
if(2 - 4 > 1){
  //2-4 = -2. -2 is smaller than 1 so the code in this block doesn't get excecuted

Even though the difference is 2 in both cases, only 1 statement would result in a 'true' statement. Luckily C++ has a function called abs(//enter your calculation here). abs() Returns the absolute difference between 2 values. This ensures that -2 and +2 both result in a value of 2.

void BitsAndDroidsFlightConnector::sendSetRudderPot(byte potPin) {
    currentRudder = smoothPot(potPin);
    //Analogdif is a value defined in the header file.
    //It represents the value changes we want to register
    //If you've got noisy pots or a range of 2048 upping this value might provide further smoothing
    //More smoothing results in a fidelity loss
    if (abs(currentRudder - oldRudderAxis) > analogDiff) {
        packagedData = sprintf(valuesBuffer, "%s %i", "901", currentRudder);
        oldRudderAxis = currentRudder;
        //valuesbuffer is a container that holds the formatted command
        //For the rudder this format is 901 X (X = the rudder value)
        //This gets translated in the connector. 901 is the identifier used to categorize the data 



The readings are smoothed out, the calculations are made, and your board sends out 901 800. The connector reads the command, maps the incoming value to the ingame axis, and we call it a day, right? Almost! In 99% of the cases, the command arrives as 901 800.

However, there is a possibility that data gets lost or corrupted. It would be a real shame to receive 901 000 when you should receive 901 800. This command would result in a full yank on your rudders to the left while you wanted to steer slightly to the right. Rudder stomping has never worked out great for me on the runway. In the connector, this gets handled by the following piece of code.

void InputSwitchHandler::setRudder(int index) {
  try {
    //The token represents a string cut up by the delimiter (in this case a space)
    token = strtok_s(receivedString[index], " ", &next_token);

    counter = 0;
    //We take the token above if it is empty we know we've reached the end of the string
    //A command like 901 800 would be read as 901 -> 800 -> null (nothing)
    while (token != nullptr && counter < 2) {
      if (counter == 1) {
        int analogValue = stoi(token);
        // minimum to first point
        rudderAxis = calibratedRange(analogValue, 0);
      token = strtok_s(nullptr, " ", &next_token);

    int diff = std::abs(rudderAxis - oldRudderAxis);
    //The ingame axis runs on a scale of -16383 tot 16383. 
    //If a change is bigger than 10000 it's most likely a faulty reading dropping the incomming value to i.e. 0
    //To prevent a hard left turn we filter out these readings here
    if (diff < 10000 || oldRudderAxis == NULL) {
          connect, 0, inputDefinitions.DEFINITION_AXIS_RUDDER_SET, rudderAxis,
      oldRudderAxis = rudderAxis;
  } catch (const std::exception &e) {
    cout << "error in rudder" << endl;

Try catch

There is a chance you've never heard of a try, catch statement. The connector doesn't control which data it receives. If you decided to replace the value with a string of characters, the entire app could crash.

Let's take 901 "Monkey" as an example command. The connector would try to store "Monkey" as the rudder value. The rudder value is an integer (nondecimal number), preventing the word "Monkey" from being saved. The app freaks out; your computer freaks out; perhaps you even freak out while the app crashes. The try block does what you'd expect. It tries to read your incoming command and execute the necessary calculations. If for whatever reason, the attempt fails, we enter the catch statement.

The catch statement catches your failed attempt before it wreaks havoc. We still have a failed attempt but instead of a crash, we can print an error message.