Wireless Game Controller V1
Greetings everyone and welcome back.

This is my prototype for a wireless Bluetooth game controller built around the Arduino Nano ESP32.
The idea behind this project is to eventually build a completely open-source game controller, from the electronics and PCB to the enclosure and firmware, so that anyone can build their own controller, modify it, or use the design as a starting point for their own projects.
This prototype is the first step towards that goal. I wanted to test the core electronics and make sure I could get a custom controller working wirelessly before designing the final hardware from scratch.
For the prototype, I reused the PCB from a wired game controller I built previously. That board was originally designed around an Arduino Pro Micro and the ATmega32U4. By replacing the Pro Micro with an Arduino Nano ESP32, I can take advantage of the ESP32-S3's built-in Bluetooth Low Energy and turn the same basic controller hardware into a wireless gamepad.
The prototype is already fully functional and can connect to a computer over Bluetooth and work as a regular game controller. I’ve been using it to play games like Cyberpunk 2077 and NieR while testing the hardware and firmware.
This isn't the final controller yet. The eventual version will have a completely custom PCB, a purpose-built enclosure, and a much more refined design. For now, this prototype is about proving that the concept works and laying the groundwork for the open-source controller project.
This article covers the complete build process of this prototype, so let's get started with the build.
MATERIALS REQUIRED
These were the components used in this build.
- Custom PCB (Salvaged from previous Project)
- Arduino Nano ESP32 Board
- Right-Angle Push Buttons
- Horizontal Push Buttons
- Analog Joysticks
- Power bank
- USB to Type C Cable
- Double-sided tape
HARDWARE—ARDUINO GAME CONTROLLER

If the Arduino Nano ESP32 is the brain of this project, then the previously made game controller PCB is definitely the brawn.
For this project, I'm reusing the main joystick and button PCB from my previous Arduino game controller project. In the original version, an Arduino Pro Micro was connected to this PCB and acted as the interface between the controller and the PC. Using the ATmega32U4's native USB HID support, I was able to control games and use the board as a regular game controller.
The most interesting part of this controller, however, is that only one I/O pin is used to control all 10 buttons on the board. I achieved this using a clever resistor voltage-divider setup, which allows the microcontroller to identify which button is being pressed by reading different voltage levels from a single analog pin. I'll explain how this works in the next step.
I had also designed this PCB with provisions for two thumbsticks, which is something I was able to take advantage of for this wireless version.
For a more detailed look at the construction and the other design decisions behind the original controller, check out its project page.
https://www.instructables.com/Arduino-Retro-Game-Controller
PCB DESIGN



For this project, I made a fairly traditional, straightforward game controller PCB, with the buttons connected to the GPIO pins of a microcontroller that supports HID, since the original version was designed as a wired game controller.
The interesting part is how I managed to connect 10 buttons using a single I/O pin.
For this, I built a resistor-ladder arrangement using twelve 1 kΩ resistors connected in series between VCC and the button inputs. Each button connects to a different point on the resistor ladder, while the other side of each switch is connected to ground.
When a button is pressed, it produces a different voltage at the shared analog input depending on its position in the resistor ladder. The microcontroller reads this voltage through its ADC and can determine which button was pressed based on the measured value.
This approach makes it possible to connect a large number of buttons to a single analog I/O pin, instead of needing a separate GPIO pin for every button. In practice, you can use this technique to fit 10–20 buttons or even more onto a single input, depending on the resistor values, ADC resolution, and how much tolerance you can accommodate.
PCBWAY SERVICE

After finalizing the design, I generated the PCB Gerber file and sent it to PCBWay for fabrication. I chose a White PCB with a Black solder mask.
The quality turned out to be excellent with a clean finish and sharp silkscreen, and everything matched the design perfectly.
Over the past ten years, PCBWay has built a strong reputation for providing reliable PCB manufacturing and assembly services, becoming a go-to choice for engineers and makers worldwide.
Honestly, if you’re making custom PCBs and not checking out PCBWay, you’re just making things harder for yourself. They also offer CNC machining and 3D printing services.
HOW THE VOLTAGE DIVIDER-BUTTON SETUP WORKS

To get values of each button, we used the Analog Serial Read Sketch from the example menu and used it to get values of each button.
void setup() {
Serial.begin(9600);
}
void loop() {
int sensorValue = analogRead(A0);
Serial.println(sensorValue);
delay(1); // delay in between reads for stability
}
The A0 analog pin is used to read the different voltage values generated by the resistor ladder when a button is pressed.
For example, pressing the UP button produces an analog reading of around 0, while pressing DOWN produces a reading of around 511. Every button produces its own distinct value.
In the code, I define the expected value for each button along with a small tolerance range. The controller continuously reads the analog value from A0 and compares it against these predefined values. If the reading falls within the expected range for a particular button, that button is considered pressed; otherwise, it is treated as released.
This is what allows the controller to identify multiple buttons through a single analog input.
And even after replacing the Pro Micro with the Arduino Nano ESP32, this part of the original controller remains exactly the same. The resistor ladder and A0 input continue to handle all of the button inputs; only the microcontroller handling those readings has changed.
HARDWARE- ARDUINO NANO ESP32

The brain of this project is the Arduino Nano ESP32, which serves as the main microcontroller for the game controller.
For this prototype, I essentially replaced the Arduino Pro Micro clone used in my previous wired controller with the Arduino Nano ESP32. While the Pro Micro is based on the ATmega32U4, the Nano ESP32 is built around the ESP32-S3, giving the controller a significant increase in processing power, memory, and connectivity.
The Arduino Pro Micro uses an 8-bit ATmega32U4 AVR microcontroller running at 16 MHz. It has 32 KB of flash memory, 2.5 KB of SRAM, and 1 KB of EEPROM. Its major advantage for the original controller was native USB support, allowing it to communicate with a computer as a USB HID device.
The Nano ESP32 uses the ESP32-S3, a 32-bit dual-core microcontroller based on the Xtensa LX7 architecture, with a clock speed of up to 240 MHz. The ESP32-S3 includes 512 KB of SRAM, 384 KB of ROM, and additional RTC memory. The Nano ESP32 board also provides external flash memory for program storage.
The biggest advantage for this project, however, is connectivity. The ESP32-S3 includes an integrated 2.4 GHz Wi-Fi radio and Bluetooth Low Energy (BLE), eliminating the need for an external Bluetooth module.
WIRING

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. 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.
CODE
Here's the main code I used in this project and it's a simple one.
#include <BleGamepad.h>
BleGamepad bleGamepad("Open-Game-Controller", "Arnov", 100);
// ---------------------------------------------------------------- amber LED
const int PIN_LED = 13;
const int LED_PWM_CHANNEL = 0;
const int LED_PWM_FREQ = 5000;
const int LED_PWM_RES = 8;
// ---------------------------------------------------------------- config
#define DEBUG_SERIAL 1
const 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;
// ---------------------------------------------------------------- pins
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;
// ---------------------------------------------------------------- mapping
#define XB_A BUTTON_1
#define XB_B BUTTON_2
#define XB_X BUTTON_3
#define XB_Y BUTTON_4
#define XB_LB BUTTON_5
#define XB_RB BUTTON_6
#define XB_LS BUTTON_9
#define XB_RS BUTTON_10
#define XB_UP BUTTON_11
#define XB_DOWN BUTTON_12
#define XB_LEFT BUTTON_13
#define XB_RIGHT BUTTON_14
struct LadderEntry {
int value;
int id;
const char *name;
};
LadderEntry ladder[] = {
{ 0, XB_UP, "UP" },
{ 510, XB_DOWN, "DOWN" },
{ 681, XB_LEFT, "LEFT" },
{ 767, XB_RIGHT, "RIGHT" },
{ 819, XB_Y, "Y" },
{ 852, XB_A, "A" },
{ 877, XB_X, "X" },
{ 900, XB_B, "B" }
};
const int LADDER_COUNT = sizeof(ladder) / sizeof(ladder[0]);
// ---------------------------------------------------------------- state
int ljXCentre = 512, ljYCentre = 512;
int rjXCentre = 512, rjYCentre = 512;
int lastLadderId = -1;
int stableLadderId = -1;
uint32_t ladderChangeMs = 0;
int prevSentId = -1;
bool prevLS = false, prevRS = false;
bool prevLT = false, prevRT = false;
bool prevConnected = false;
// ---------------------------------------------------------------- helpers
int readAveraged(int pin) {
long sum = 0;
for (int i = 0; i < SAMPLES; i++) sum += analogRead(pin);
return sum / SAMPLES;
}
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;
}
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);
}
float axisMagnitude(int raw, int centre) {
int delta = abs(raw - centre);
if (delta < STICK_DEADZONE) return 0.0f;
delta -= STICK_DEADZONE;
int span = max(1023 - centre, centre) - STICK_DEADZONE;
if (span < 1) span = 1;
float m = (float)delta / (float)span;
if (m > 1.0f) m = 1.0f;
return m;
}
void ledSet(uint8_t brightness) {
ledcWrite(LED_PWM_CHANNEL, brightness);
}
// Common anode: LOW = on, HIGH = off.
void statusLedSet(bool red, bool green, bool blue) {
digitalWrite(LED_RED, red ? LOW : HIGH);
digitalWrite(LED_GREEN, green ? LOW : HIGH);
digitalWrite(LED_BLUE, blue ? LOW : HIGH);
}
// ---------------------------------------------------------------- setup
void setup() {
#if DEBUG_SERIAL
Serial.begin(115200);
#endif
analogReadResolution(10);
analogSetAttenuation(ADC_11db);
pinMode(PIN_LADDER, INPUT);
pinMode(PIN_LJ_SW, INPUT_PULLUP);
pinMode(PIN_RJ_SW, INPUT_PULLUP);
pinMode(PIN_BTN_LT, INPUT_PULLUP);
pinMode(PIN_BTN_RT, INPUT_PULLUP);
ledcSetup(LED_PWM_CHANNEL, LED_PWM_FREQ, LED_PWM_RES);
ledcAttachPin(PIN_LED, LED_PWM_CHANNEL);
ledSet(0);
pinMode(LED_RED, OUTPUT);
pinMode(LED_GREEN, OUTPUT);
pinMode(LED_BLUE, OUTPUT);
statusLedSet(true, false, false); // red at boot, not connected yet
delay(400);
ljXCentre = readAveraged(PIN_LJ_X);
ljYCentre = readAveraged(PIN_LJ_Y);
rjXCentre = readAveraged(PIN_RJ_X);
rjYCentre = readAveraged(PIN_RJ_Y);
BleGamepadConfiguration cfg;
cfg.setAutoReport(false);
cfg.setButtonCount(14);
cfg.setHatSwitchCount(0);
cfg.setWhichAxes(true, true, true, false, false, true, false, false);
cfg.setIncludeStart(true);
cfg.setIncludeSelect(true);
bleGamepad.begin(&cfg);
}
// ---------------------------------------------------------------- loop
void loop() {
bool connected = bleGamepad.isConnected();
if (connected != prevConnected) {
statusLedSet(!connected, connected, false);
prevConnected = connected;
}
if (!connected) {
ledSet(0);
delay(100);
return;
}
int raw = readAveraged(PIN_LADDER);
int id = matchLadder(raw);
if (id != lastLadderId) {
lastLadderId = id;
ladderChangeMs = millis();
}
if (millis() - ladderChangeMs >= DEBOUNCE_MS) {
stableLadderId = id;
}
if (stableLadderId != prevSentId) {
if (prevSentId >= 0) bleGamepad.release(prevSentId);
if (stableLadderId >= 0) bleGamepad.press(stableLadderId);
prevSentId = stableLadderId;
}
bool lt = (digitalRead(PIN_BTN_LT) == LOW);
bool rt = (digitalRead(PIN_BTN_RT) == LOW);
if (lt != prevLT) { lt ? bleGamepad.press(XB_LB) : bleGamepad.release(XB_LB); prevLT = lt; }
if (rt != prevRT) { rt ? bleGamepad.press(XB_RB) : bleGamepad.release(XB_RB); prevRT = rt; }
bool ls = (digitalRead(PIN_LJ_SW) == LOW);
bool rs = (digitalRead(PIN_RJ_SW) == LOW);
if (ls != prevLS) { ls ? bleGamepad.press(XB_LS) : bleGamepad.release(XB_LS); prevLS = ls; }
if (rs != prevRS) { rs ? bleGamepad.press(XB_RS) : bleGamepad.release(XB_RS); prevRS = rs; }
int ljXRaw = readAveraged(PIN_LJ_X);
int ljYRaw = readAveraged(PIN_LJ_Y);
int rjXRaw = readAveraged(PIN_RJ_X);
int rjYRaw = readAveraged(PIN_RJ_Y);
bleGamepad.setX (axisToBle(ljXRaw, ljXCentre, INVERT_LJ_X));
bleGamepad.setY (axisToBle(ljYRaw, ljYCentre, INVERT_LJ_Y));
bleGamepad.setZ (axisToBle(rjXRaw, rjXCentre, INVERT_RJ_X));
bleGamepad.setRZ(axisToBle(rjYRaw, rjYCentre, INVERT_RJ_Y));
bleGamepad.sendReport();
bool anyButtonHeld = (stableLadderId >= 0) || lt || rt || ls || rs;
if (anyButtonHeld) {
ledSet(255);
} else {
float ljMag = max(axisMagnitude(ljXRaw, ljXCentre), axisMagnitude(ljYRaw, ljYCentre));
float rjMag = max(axisMagnitude(rjXRaw, rjXCentre), axisMagnitude(rjYRaw, rjYCentre));
float mag = max(ljMag, rjMag);
ledSet((uint8_t)(mag * 255));
}
#if DEBUG_SERIAL
static uint32_t lastPrint = 0;
if (millis() - lastPrint > 250) {
lastPrint = millis();
Serial.print("ladder "); Serial.print(raw);
Serial.print(" LT "); Serial.print(lt);
Serial.print(" RT "); Serial.print(rt);
Serial.print(" LJ "); Serial.print(ljXRaw); Serial.print("/"); Serial.print(ljYRaw);
Serial.print(" RJ "); Serial.print(rjXRaw); Serial.print("/"); Serial.println(rjYRaw);
}
#endif
delay(10);
}
It is fairly straightforward, with most of the Bluetooth HID functionality being handled by the BleGamepad library.
Bluetooth Gamepad Library
#include <BleGamepad.h>
BleGamepad bleGamepad("Open-Game-Controller", "Arnov", 100);
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 1
const 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.
Mapping the Physical Buttons
#define XB_A BUTTON_1
#define XB_B BUTTON_2
#define XB_X BUTTON_3
#define XB_Y BUTTON_4
#define XB_LB BUTTON_5
#define XB_RB BUTTON_6
#define XB_LS BUTTON_9
#define XB_RS BUTTON_10
#define XB_UP BUTTON_11
#define XB_DOWN BUTTON_12
#define XB_LEFT BUTTON_13
#define XB_RIGHT BUTTON_14
Here I map the physical controls to the button IDs expected by the BleGamepad library.
For example, pressing the physical A button ultimately results in:
bleGamepad.press(XB_A);
which corresponds to BUTTON_1 in the HID gamepad report.
I am using an Xbox-style button layout for the controller, which makes it compatible with games expecting a standard gamepad.
The Resistor Ladder
struct LadderEntry {
int value;
int id;
const char *name;
};
LadderEntry ladder[] = {
{ 0, XB_UP, "UP" },
{ 510, XB_DOWN, "DOWN" },
{ 681, XB_LEFT, "LEFT" },
{ 767, XB_RIGHT, "RIGHT" },
{ 819, XB_Y, "Y" },
{ 852, XB_A, "A" },
{ 877, XB_X, "X" },
{ 900, XB_B, "B" }
};
This is where the resistor ladder becomes useful.
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:
850 853 851 854 852 851 853 852
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 center position, applies the deadzone, and then scales the remaining movement.
The result is approximately:
Joystick left → 0
Joystick centre → 16384
Joystick right → 32767
The invert parameter allows the direction of the axis to be reversed when necessary.
Initialising the Controller
void setup() {
Serial.begin(115200);
analogReadResolution(10);
analogSetAttenuation(ADC_11db);
pinMode(PIN_LADDER, INPUT);
pinMode(PIN_LJ_SW, INPUT_PULLUP);
pinMode(PIN_RJ_SW, INPUT_PULLUP);
pinMode(PIN_BTN_LT, INPUT_PULLUP);
pinMode(PIN_BTN_RT, INPUT_PULLUP);
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.
Joystick Calibration
ljXCentre = readAveraged(PIN_LJ_X);
ljYCentre = readAveraged(PIN_LJ_Y);
rjXCentre = readAveraged(PIN_RJ_X);
rjYCentre = readAveraged(PIN_RJ_Y);
During startup, the controller measures the resting position of all four joystick axes.
Instead of assuming the centre is exactly 512, the actual position of each joystick is measured and stored.
This is useful because real joystick modules aren't perfectly identical and their centre values can vary slightly.
The only catch is that the joysticks need to be left untouched while the controller is starting up.
Configuring the Bluetooth Gamepad
BleGamepadConfiguration cfg;
cfg.setAutoReport(false);
cfg.setButtonCount(14);
cfg.setHatSwitchCount(0);
cfg.setWhichAxes(
true, true, true, false,
false, true, false, false
);
cfg.setIncludeStart(true);
cfg.setIncludeSelect(true);
bleGamepad.begin(&cfg);
Here I configure the Bluetooth HID device itself.
The controller exposes 14 buttons and the required analog axes. I also disable automatic reporting with setAutoReport(false).
This means the firmware decides when to send the current controller state rather than sending a report every time an individual input changes.
Once everything is configured, bleGamepad.begin() starts the Bluetooth gamepad.
The Main Loop
void loop() {
bool connected = bleGamepad.isConnected();
if (!connected) {
ledSet(0);
delay(100);
return;
}
// Read controller inputs...
bleGamepad.sendReport();
delay(10);
}
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.
Reading the Joysticks
int ljXRaw = readAveraged(PIN_LJ_X);
int ljYRaw = readAveraged(PIN_LJ_Y);
int rjXRaw = readAveraged(PIN_RJ_X);
int rjYRaw = readAveraged(PIN_RJ_Y);
bleGamepad.setX(
axisToBle(ljXRaw, ljXCentre, INVERT_LJ_X)
);
bleGamepad.setY(
axisToBle(ljYRaw, ljYCentre, INVERT_LJ_Y)
);
bleGamepad.setZ(
axisToBle(rjXRaw, rjXCentre, INVERT_RJ_X)
);
bleGamepad.setRZ(
axisToBle(rjYRaw, rjYCentre, INVERT_RJ_Y)
);
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, the 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.
The complete process can be summarized as
Physical controls → ADC/GPIO → Input processing → HID values → BleGamepad → Bluetooth → Computer
And that's essentially how the prototype turns a collection of buttons and analog joysticks into a functional wireless game controller.
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.
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.
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.
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.
STEAM

Now, this isn't technically an XInput controller, which means Windows won't necessarily recognize 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.
PLAYING GAMES


We begin the testing by playing NieR. There wasn't any particular reason for choosing it; I just really liked 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.
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.
Check out my other similar projects—
https://www.instructables.com/Arduino-Retro-Game-Controller
https://www.instructables.com/DIY-XBOX-Controller
https://www.instructables.com/SUPER-Nintendo-Entertainment-System-Controller-XL
For now, this version of the project is complete. Version 2 is already in the works, so stay tuned for that.
Peace.
#include <BleGamepad.h>
BleGamepad bleGamepad("Open-Game-Controller", "Arnov", 100);
// ---------------------------------------------------------------- amber LED
const int PIN_LED = 13;
const int LED_PWM_CHANNEL = 0;
const int LED_PWM_FREQ = 5000;
const int LED_PWM_RES = 8;
// ---------------------------------------------------------------- config
#define DEBUG_SERIAL 1
const 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;
// ---------------------------------------------------------------- pins
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;
// ---------------------------------------------------------------- mapping
#define XB_A BUTTON_1
#define XB_B BUTTON_2
#define XB_X BUTTON_3
#define XB_Y BUTTON_4
#define XB_LB BUTTON_5
#define XB_RB BUTTON_6
#define XB_LS BUTTON_9
#define XB_RS BUTTON_10
#define XB_UP BUTTON_11
#define XB_DOWN BUTTON_12
#define XB_LEFT BUTTON_13
#define XB_RIGHT BUTTON_14
struct LadderEntry {
int value;
int id;
const char *name;
};
LadderEntry ladder[] = {
{ 0, XB_UP, "UP" },
{ 510, XB_DOWN, "DOWN" },
{ 681, XB_LEFT, "LEFT" },
{ 767, XB_RIGHT, "RIGHT" },
{ 819, XB_Y, "Y" },
{ 852, XB_A, "A" },
{ 877, XB_X, "X" },
{ 900, XB_B, "B" }
};
const int LADDER_COUNT = sizeof(ladder) / sizeof(ladder[0]);
// ---------------------------------------------------------------- state
int ljXCentre = 512, ljYCentre = 512;
int rjXCentre = 512, rjYCentre = 512;
int lastLadderId = -1;
int stableLadderId = -1;
uint32_t ladderChangeMs = 0;
int prevSentId = -1;
bool prevLS = false, prevRS = false;
bool prevLT = false, prevRT = false;
bool prevConnected = false;
// ---------------------------------------------------------------- helpers
int readAveraged(int pin) {
long sum = 0;
for (int i = 0; i < SAMPLES; i++) sum += analogRead(pin);
return sum / SAMPLES;
}
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;
}
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);
}
float axisMagnitude(int raw, int centre) {
int delta = abs(raw - centre);
if (delta < STICK_DEADZONE) return 0.0f;
delta -= STICK_DEADZONE;
int span = max(1023 - centre, centre) - STICK_DEADZONE;
if (span < 1) span = 1;
float m = (float)delta / (float)span;
if (m > 1.0f) m = 1.0f;
return m;
}
void ledSet(uint8_t brightness) {
ledcWrite(LED_PWM_CHANNEL, brightness);
}
// Common anode: LOW = on, HIGH = off.
void statusLedSet(bool red, bool green, bool blue) {
digitalWrite(LED_RED, red ? LOW : HIGH);
digitalWrite(LED_GREEN, green ? LOW : HIGH);
digitalWrite(LED_BLUE, blue ? LOW : HIGH);
}
// ---------------------------------------------------------------- setup
void setup() {
#if DEBUG_SERIAL
Serial.begin(115200);
#endif
analogReadResolution(10);
analogSetAttenuation(ADC_11db);
pinMode(PIN_LADDER, INPUT);
pinMode(PIN_LJ_SW, INPUT_PULLUP);
pinMode(PIN_RJ_SW, INPUT_PULLUP);
pinMode(PIN_BTN_LT, INPUT_PULLUP);
pinMode(PIN_BTN_RT, INPUT_PULLUP);
ledcSetup(LED_PWM_CHANNEL, LED_PWM_FREQ, LED_PWM_RES);
ledcAttachPin(PIN_LED, LED_PWM_CHANNEL);
ledSet(0);
pinMode(LED_RED, OUTPUT);
pinMode(LED_GREEN, OUTPUT);
pinMode(LED_BLUE, OUTPUT);
statusLedSet(true, false, false); // red at boot, not connected yet
delay(400);
ljXCentre = readAveraged(PIN_LJ_X);
ljYCentre = readAveraged(PIN_LJ_Y);
rjXCentre = readAveraged(PIN_RJ_X);
rjYCentre = readAveraged(PIN_RJ_Y);
BleGamepadConfiguration cfg;
cfg.setAutoReport(false);
cfg.setButtonCount(14);
cfg.setHatSwitchCount(0);
cfg.setWhichAxes(true, true, true, false, false, true, false, false);
cfg.setIncludeStart(true);
cfg.setIncludeSelect(true);
bleGamepad.begin(&cfg);
}
// ---------------------------------------------------------------- loop
void loop() {
bool connected = bleGamepad.isConnected();
if (connected != prevConnected) {
statusLedSet(!connected, connected, false);
prevConnected = connected;
}
if (!connected) {
ledSet(0);
delay(100);
return;
}
int raw = readAveraged(PIN_LADDER);
int id = matchLadder(raw);
if (id != lastLadderId) {
lastLadderId = id;
ladderChangeMs = millis();
}
if (millis() - ladderChangeMs >= DEBOUNCE_MS) {
stableLadderId = id;
}
if (stableLadderId != prevSentId) {
if (prevSentId >= 0) bleGamepad.release(prevSentId);
if (stableLadderId >= 0) bleGamepad.press(stableLadderId);
prevSentId = stableLadderId;
}
bool lt = (digitalRead(PIN_BTN_LT) == LOW);
bool rt = (digitalRead(PIN_BTN_RT) == LOW);
if (lt != prevLT) { lt ? bleGamepad.press(XB_LB) : bleGamepad.release(XB_LB); prevLT = lt; }
if (rt != prevRT) { rt ? bleGamepad.press(XB_RB) : bleGamepad.release(XB_RB); prevRT = rt; }
bool ls = (digitalRead(PIN_LJ_SW) == LOW);
bool rs = (digitalRead(PIN_RJ_SW) == LOW);
if (ls != prevLS) { ls ? bleGamepad.press(XB_LS) : bleGamepad.release(XB_LS); prevLS = ls; }
if (rs != prevRS) { rs ? bleGamepad.press(XB_RS) : bleGamepad.release(XB_RS); prevRS = rs; }
int ljXRaw = readAveraged(PIN_LJ_X);
int ljYRaw = readAveraged(PIN_LJ_Y);
int rjXRaw = readAveraged(PIN_RJ_X);
int rjYRaw = readAveraged(PIN_RJ_Y);
bleGamepad.setX (axisToBle(ljXRaw, ljXCentre, INVERT_LJ_X));
bleGamepad.setY (axisToBle(ljYRaw, ljYCentre, INVERT_LJ_Y));
bleGamepad.setZ (axisToBle(rjXRaw, rjXCentre, INVERT_RJ_X));
bleGamepad.setRZ(axisToBle(rjYRaw, rjYCentre, INVERT_RJ_Y));
bleGamepad.sendReport();
bool anyButtonHeld = (stableLadderId >= 0) || lt || rt || ls || rs;
if (anyButtonHeld) {
ledSet(255);
} else {
float ljMag = max(axisMagnitude(ljXRaw, ljXCentre), axisMagnitude(ljYRaw, ljYCentre));
float rjMag = max(axisMagnitude(rjXRaw, rjXCentre), axisMagnitude(rjYRaw, rjYCentre));
float mag = max(ljMag, rjMag);
ledSet((uint8_t)(mag * 255));
}
#if DEBUG_SERIAL
static uint32_t lastPrint = 0;
if (millis() - lastPrint > 250) {
lastPrint = millis();
Serial.print("ladder "); Serial.print(raw);
Serial.print(" LT "); Serial.print(lt);
Serial.print(" RT "); Serial.print(rt);
Serial.print(" LJ "); Serial.print(ljXRaw); Serial.print("/"); Serial.print(ljYRaw);
Serial.print(" RJ "); Serial.print(rjXRaw); Serial.print("/"); Serial.println(rjYRaw);
}
#endif
delay(10);
}
Wireless Game Controller V1
Project images are for reference only. Actual production is based on the manufacturing files on the project page.
Please review the designer's notes (e.g., PCB thickness) and select the appropriate options.
PCBWay is not responsible
for issues caused by unsuitable parameter selections.
For more important ordering information, please refer to
Read More
Raspberry Pi 5 7 Inch Touch Screen IPS 1024x600 HD LCD HDMI-compatible Display for RPI 4B 3B+ OPI 5 AIDA64 PC Secondary Screen(Without Speaker)
BUY NOW- Comments(1)
- Likes(0)
- 0 USER VOTES
- YOUR VOTE 0.00 0.00
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
More by Arnov Arnov sharma
-
DIY XBOX Controller
Greetings everyone, and welcome back. Here's something fun and custom.This is my version of an Xbox ...
-
Pocket SNES
Greetings everyone, and welcome back! Today, I’ve got something fun and tiny to share—the Pocket SNE...
-
Batocera Arcade Box
Greetings everyone and welcome back, Here's something. Fun and nostalgic. Right now, we are using ou...
-
64x32 Matrix Panel Setup with PICO 2
Greetings everyone and welcome back.So here's something fun and useful: a Raspberry Pi Pico 2-powere...
-
Portable Air Quality Meter
Hello everyone, and welcome back! Today, I have something incredibly useful for you—a Portable Air Q...
-
WALKPi PCB Version
Greetings everyone and welcome back, This is the WalkPi, a homebrew audio player that plays music fr...
-
Delete Button XL
Greetings everyone and welcome back, and here's something fun and useful.In essence, the Delete Butt...
-
Arduino Retro Game Controller
Greetings everyone and welcome back. Here's something fun.The Arduino Retro Game Controller was buil...
-
Super Power Buck Converter
Greetings everyone and welcome back!Here's something powerful, The SUPER POWER BUCK CONVERTER BOARD ...
-
Pocket Temp Meter
Greetings and welcome back.So here's something portable and useful: the Pocket TEMP Meter project.As...
-
Pico Powered DC Fan Driver
Hello everyone and welcome back.So here's something cool: a 5V to 12V DC motor driver based around a...
-
Mini Solar Light Project with a Twist
Greetings.This is the Cube Light, a Small and compact cube-shaped emergency solar light that boasts ...
-
PALPi V5 Handheld Retro Game Console
Hey, Guys what's up?So this is PALPi which is a Raspberry Pi Zero W Based Handheld Retro Game Consol...
-
DIY Thermometer with TTGO T Display and DS18B20
Greetings.So this is the DIY Thermometer made entirely from scratch using a TTGO T display board and...
-
Motion Trigger Circuit with and without Microcontroller
GreetingsHere's a tutorial on how to use an HC-SR505 PIR Module with and without a microcontroller t...
-
Motor Driver Board Atmega328PU and HC01
Hey, what's up folks here's something super cool and useful if you're making a basic Robot Setup, A ...
-
Power Block
Hey Everyone what's up!So this is Power block, a DIY UPS that can be used to power a bunch of 5V Ope...
-
Goku PCB Badge V2
Hey everyone what's up!So here's something SUPER cool, A PCB Board themed after Goku from Dragon Bal...
-
Programmable Mist Maker - XIAO / QT PY Extension
2184 2 2 -
RadioHAT - Raspberry Pi radio development platform
1872 0 4 -
QWIIC-VL53L4CD Time-of-Flight Distance Sensor Module
2071 0 2 -
-
-
ARPS-2 – Arduino-Compatible Robot Project Shield for Arduino UNO
4064 0 6 -
-
A Compact Charging Breakout Board For Waveshare ESP32-C3
4817 3 8 -
AI-driven LoRa & LLM-enabled Kiosk & Food Delivery System
5621 2 2 -







