Wiring this board was quite straightforward. I was able to use a single analog pin for all the A, B, X, Y buttons and D-pad inputs using a resistor ladder, with A0 handling the combined input.
The rest of the controls were connected as follows:
Right joystick Y: A1
Right joystick X: A2
Left joystick Y: A3
Left joystick X: D4
Right joystick switch: D2
Left joystick switch: D3
LT: D6
RT: D7
I initially ran into some issues with the LT and RT inputs, so I ended up connecting them directly to D6 and D7 respectively. For making connections, I use single-core silver copper wire.
2
CODE
Here's the main code I used in this project and its a simple one.
The BleGamepad library handles the Bluetooth HID side of the project, allowing the Nano ESP32 to identify itself as a wireless game controller.
The first line includes the library, while the second creates the gamepad instance. Here I have named the controller Open-Game-Controller, set the manufacturer name to Arnov, and set the reported battery level to 100%.
Controller Configuration
#define DEBUG_SERIAL 1const int MATCH_TOLERANCE = 30;const int IDLE_THRESHOLD = 1000;const int SAMPLES = 8;const int STICK_DEADZONE = 40;const bool INVERT_LJ_X = false;const bool INVERT_LJ_Y = true;const bool INVERT_RJ_X = false;const bool INVERT_RJ_Y = true;const uint32_t DEBOUNCE_MS = 25;
These variables define some of the basic behaviour of the controller.
SAMPLES determines how many ADC readings are taken when reading an analog input. I use eight samples and average them to reduce noise.
STICK_DEADZONE defines how much movement around the joystick's centre position is ignored. This prevents small fluctuations in the joystick from being interpreted as movement.
The INVERT variables allow me to reverse an axis if the physical orientation of the joystick causes its movement to be interpreted backwards.
MATCH_TOLERANCE is used for detecting the buttons connected through the resistor ladder, while DEBOUNCE_MS prevents short fluctuations from being interpreted as multiple button presses.
Pin Mapping
const int PIN_LADDER = A0;const int PIN_RJ_Y = A1;const int PIN_RJ_X = A2;const int PIN_LJ_Y = A3;const int PIN_LJ_X = D4;const int PIN_RJ_SW = D2;const int PIN_LJ_SW = D3;const int PIN_BTN_LT = D6;const int PIN_BTN_RT = D7;
This section defines where each physical control is connected to the Nano ESP32.
The A, B, X, Y buttons and D-pad share A0 through the resistor ladder. The two joysticks use four analog inputs for their X and Y axes.
The joystick-click switches, LT and RT are connected to digital GPIO pins.
Instead of dedicating a separate GPIO pin to every button, each button produces a different voltage that can be measured through the single analog pin A0.
For example, an ADC reading around 852 represents the A button, while around 900 represents B.
The controller compares the measured ADC value against this table and selects the closest matching button.
This allows eight different inputs to share a single analog pin, saving a significant number of GPIOs.
Averaging Analog Readings
int readAveraged(int pin) { long sum = 0; for (int i = 0; i < SAMPLES; i++) sum += analogRead(pin); return sum / SAMPLES;}
Analog readings are not perfectly stable, so instead of taking one ADC reading, I take eight and calculate their average.
For example, if the ADC returns:
850853851854852851853852
the firmware averages these values before using them.
This gives us a more stable reading for both the joysticks and the resistor ladder.
Detecting the Button Ladder
int matchLadder(int value) { if (value >= IDLE_THRESHOLD) return -1; int bestId = -1; int bestDist = MATCH_TOLERANCE + 1; for (int i = 0; i < LADDER_COUNT; i++) { int dist = abs(value - ladder[i].value); if (dist < bestDist) { bestDist = dist; bestId = ladder[i].id; } } return bestId;}
This function takes the ADC value from A0 and figures out which button is being pressed.
If the reading is above the idle threshold, the controller assumes that no button is pressed.
Otherwise, it checks every value in the resistor ladder table and calculates the distance between the measured value and the expected value.
The closest match becomes the detected button.
The tolerance value prevents completely unrelated ADC readings from being treated as a valid button press.
Converting Joystick Input
int16_t axisToBle(int raw, int centre, bool invert) { int delta = raw - centre; if (abs(delta) < STICK_DEADZONE) return 16384; delta += (delta > 0) ? -STICK_DEADZONE : STICK_DEADZONE; int span = (delta > 0) ? (1023 - centre - STICK_DEADZONE) : (centre - STICK_DEADZONE); if (span < 1) span = 1; long scaled = (long)delta * 16383 / span; if (scaled > 16383) scaled = 16383; if (scaled < -16383) scaled = -16383; if (invert) scaled = -scaled; return (int16_t)(16384 + scaled);}
This function converts the raw ADC value from the joystick into the range expected by the Bluetooth HID gamepad.
The Nano ESP32 gives us a 10-bit ADC value from 0 to 1023, while the HID axis is represented using a larger signed range.
The function first subtracts the calibrated centre position, applies the deadzone, and then scales the remaining movement.
The result is approximately:
Joystick left → 0Joystick centre → 16384Joystick right → 32767
The invert parameter allows the direction of the axis to be reversed when necessary.
The setup() function runs once when the controller powers on.
Here I initialise the Serial connection for debugging, configure the ADC, and set the digital button pins as inputs using the ESP32's internal pull-up resistors.
Using INPUT_PULLUP means the button reads HIGH when released and LOW when pressed.
The loop() function is where the controller continuously reads its inputs.
First, it checks whether a Bluetooth device is connected. If there isn't one, it simply waits.
Once connected, the firmware reads the buttons and joystick positions, processes the inputs, updates the gamepad state, and finally sends the complete report using:
bleGamepad.sendReport();
The loop then repeats roughly every 10 milliseconds.
The four joystick axes are read, averaged, converted into HID values, and then assigned to the corresponding gamepad axes.
The left joystick controls X and Y, while the right joystick is assigned to Z and RZ.
At this point, the physical movement of the joysticks has been converted into the digital values expected by the Bluetooth gamepad.
Sending the Final Report
bleGamepad.sendReport();
Finally, all the current button and joystick states are sent to the connected device as a Bluetooth HID report.
The computer therefore doesn't need to know anything about the individual buttons, resistor ladder, joystick ADC values, or the Arduino itself. As far as the operating system is concerned, it is simply communicating with a standard Bluetooth game controller.
And that's essentially how the prototype turns a collection of buttons and analog joysticks into a functional wireless game controller.
3
POWER SOURCE
With the firmware flashed, the game controller is ready to go. There is just one problem: it is still wired.
The controller needs to be powered, and for now, that means connecting a USB Type-C cable to the Nano ESP32.
To get around this temporary limitation, I decided to use a MagSafe power bank. Not because it has MagSafe or anything fancy like that, I just happened to have one lying around.
The power bank is capable of delivering USB Power Delivery (PD) outputs, but the controller doesn't need anything nearly that complicated. For this prototype, all we need is a regular 5 V supply capable of delivering up to 2 A.
In reality, the controller should draw considerably less than 2 A. Based on the hardware used in this prototype, the actual consumption should be somewhere around a few hundred milliamps, and potentially even below 200 mA, depending on the operating conditions and connected peripherals.
For a prototype, though, having a power bank capable of supplying 5 V at 2 A gives us plenty of headroom.
With the power bank attached to the back, we now have a completely wireless controller, at least until the power bank inevitably becomes the most structurally important part of the entire assembly.
4
ASSEMBLY
The assembly process begins by adding double-sided foam tape to the back of the power bank.
After removing the protective covering, I positioned the game controller PCB against the power bank, pressed both parts firmly together, and secured them in place.
Next, I added a smaller piece of foam tape to the opposite side of the circuit and used it to mount the Arduino Nano ESP32. A little pressure was enough to hold the Arduino board and the controller PCB securely in place.
Finally, I connected the power bank to the Arduino using a USB cable, one end connected to the power bank and the other to the Arduino's USB Type-C port.
And with that, the makeshift assembly is complete.
5
RESULT
And here's the end result of this makeshift method of assembly: a working controller that honestly looks like something James channel would make.
Jokes aside, this is a fully functional wireless game controller project. It's definitely rough around the edges, but it proves that the concept works and gives me a solid starting point for the next revision.
6
SETTING UP CONTROLLER
To test whether the controller actually works, the first thing we need to do is pair it with a computer.
We open the Bluetooth settings and look for our controller, which should show up as Open Game Controller. Once it appears, we pair it with the computer and make sure the connection is established.
When the controller is disconnected or hasn't connected to a device yet, the RGB LED stays red. Once the controller connects over Bluetooth, the LED switches to green, giving a simple visual indication that the controller is connected and ready to use.
After that, we head over to the browser and open Gamepad Tester, which is a really useful tool for checking game controllers. Here, we can test every button individually to make sure the inputs are being detected correctly. We can also check both joysticks and make sure their movement and range are being registered properly.
Once we've gone through all the buttons and joysticks and confirmed that everything is working, we move on to the next step, actually using the controller with Steam.
7
STEAM
Now, this isn't technically an XInput controller, which means Windows won't necessarily recognise it straight away as a proper Xbox-style game controller. Some games might still detect it, but a lot of them won't.
So here's a little clever workaround.
We open Steam in Big Picture Mode, head over to the controller settings, and set up a new controller. From there, Steam lets us map the inputs manually. We go through the controls one by one and assign each button, trigger, D-pad direction, and joystick.
Once everything is mapped, Steam handles the translation for us, and our controller can now work with games that support standard controller input.
It's not quite native XInput, but for a prototype, this gets the job done pretty well.
8
PLAYING GAMES
We begin the testing by playing NieR. There wasn't any particular reason for choosing it, I just really like NieR and wanted to see how one of my favorite games would work with my custom game controller.
And honestly, I had no issues whatsoever. Everything felt snappy and worked pretty much exactly as it should.
There was definitely a noticeable difference in the quality of the thumbsticks compared to the ones used in an Xbox controller. The sticks I'm using in this prototype aren't exactly high-end, so the precision and overall feel aren't quite there yet. That could definitely be improved by using higher-precision Hall-effect joysticks, but that's something I'll be looking at in a future revision.
The next game I tried was Cyberpunk 2077, and this is where I really started to appreciate how good a proper Xbox controller actually is and how questionable mine currently is.
Using the controller in a game like Cyberpunk felt noticeably worse. The thumbsticks weren't precise enough for aiming, and I definitely couldn't reliably hit or track enemies. The controls felt a little slow and awkward compared to an actual Xbox controller.
But the important part is that it worked.
The controller connected wirelessly, the inputs were detected correctly, and I could actually play a full game with it. For this revision, that's exactly what I was aiming for.
The controller doesn't need to be perfect yet. It just needs to prove that the concept works and it does.
9
OVERVIEW & WHATS NEXT
So, this was my open-source wireless game controller project, and it works.
The main goal with this version was simply to get a prototype working and make it as good as I could before moving on to the next revision. But eventually, I want to turn this into something bigger: a community-driven, open-source game controller project.
I'm not entirely sure yet how far I can take that idea, but that's the goal. I want this to eventually become a controller that people can build themselves, modify, contribute to, and hopefully improve together. And I'll be working towards that with Version 2.
For now, the project has a fairly bare-bones circuit, which I'm already redesigning from scratch. I might keep the Arduino board for the next revision, or remove it completely and switch to an ESP32 module to save some space and make the electronics more compact.
The controller's physical design is also being developed from scratch. I'm collaborating with a designer buddy of mine, and we're going through a proper industrial design process for the body of the controller. The goal is to create something ergonomic, functional, and most importantly easy for anyone to 3D print and build themselves.
And that's really the main goal of this project: making a proper open-source controller that isn't just a project I built, but something other people can actually build on too.
I hope this project was at least somewhat helpful. If it was, hit that like button. And if you have any questions about the project, feel free to reach out to me on Instagram or leave a comment here.
Discussions
Become a Hackaday.io Member
Create an account to leave a comment. Already have an account? Log In.