Community project
ESP32 WiFi Game Controller
Generated with AIBuild a WiFi-enabled game controller using an ESP32 and a dual-axis joystick module. The controller connects to a local WiFi network and streams joystick input (X and Y axes plus button press) as JSON data over HTTP, making it perfect for wireless gaming projects, remote control applications, or custom game interfaces.
This guide provides a complete wiring diagram, parts list, and ready-to-upload firmware that turns the ESP32 into a web-based gamepad. The included HTML interface lets you test the controller immediately in a web browser, and the normalized axis output with configurable dead zone makes it suitable for precise control in games or robotics applications.
Wiring diagram
Interactive · read-only
Pan and zoom to explore the wiring. Remix the project to edit it in your own workspace.
Parts list
Bill of materials| Component | Qty | Notes |
|---|---|---|
| KY-023 Dual Axis Joystick Module3.3 V | 1 | Dual-axis analog joystick breakout (PSP/PS2-style thumbstick) with two perpendicular 10 kOhm potentiometers and an integrated push-button. Outputs analog voltages on VRx and VRy proportional to stick position, plus an active-low digital switch (SW) that is pulled LOW when the stick is pressed. Operates 3.3 V to 5 V; on 5 V boards the VRx/VRy swing matches the wider ADC range. SW should be read with INPUT_PULLUP. |
Assembly
4 stepsPower down before wiring
Unplug the ESP32 DevKit v1 USB cable before attaching the joystick.
- Tip: Keep the board on a non-conductive surface while wiring.
- ⚠ Do not wire or rewire while the board is powered.
Connect joystick power safely
Wire joystick_1 VCC to the ESP32 3V3 pin and joystick_1 GND to an ESP32 GND pin.
- Tip: Use the pins labelled 3V3 and GND on the ESP32.
- ⚠ Do not connect the joystick VCC to ESP32 5V/VIN. Supplying this joystick at 3.3 V keeps its analog signals safe for the ESP32.
Connect the joystick controls
Connect joystick_1 VRx to GPIO34, VRy to GPIO35, and SW to GPIO27. The SW pin is the push switch built into the joystick cap.
- Tip: GPIO34 and GPIO35 are analog-input pins used for horizontal and vertical movement.
- Tip: The firmware enables an internal pull-up on GPIO27, so the joystick switch connects it to ground when pressed.
- ⚠ Check each wire against its printed joystick label: VRx, VRy, and SW are different connections.
Power and play
Connect the ESP32 by USB. Join the ESP32-Gamepad Wi-Fi network with password gamepad123, then open http://192.168.4.1/ in a browser. Move the joystick to steer Snake; press the joystick cap to start or restart.
- Tip: The game sound is played through the phone or computer browser; tap/click the page once if the browser asks to enable audio.
- ⚠ Use Schematik's Deploy button to flash the firmware before playing.
Pin assignments
Board wiring reference| Pin | Connection | Type |
|---|---|---|
| 3V3 | joystick_1 VCC | power |
| GND | joystick_1 GND | ground |
| GPIO 34 | joystick_1 VRx | adc |
| GPIO 35 | joystick_1 VRy | adc |
| GPIO 27 | joystick_1 SW | digital |
Firmware
ESP32#include <Arduino.h>
#include <WiFi.h>
#include <WebServer.h>
// Forward declarations
int normalizeAxis(int raw);
String controllerJson();
constexpr int JOY_X_PIN = 34;
constexpr int JOY_Y_PIN = 35;
constexpr int JOY_SW_PIN = 27;
const char *AP_SSID = "ESP32-Gamepad";
const char *AP_PASSWORD = "gamepad123";
WebServer server(80);
int normalizeAxis(int raw) {
const int value = raw - 2048;
const int deadZone = 260;
if (abs(value) < deadZone) return 0;
return value > 0 ? map(value, deadZone, 2047, 1, 100)
: map(value, -2048, -deadZone, -100, -1);
}
String controllerJson() {
const int x = normalizeAxis(analogRead(JOY_X_PIN));
const int y = normalizeAxis(analogRead(JOY_Y_PIN));
String json = "{\"x\":" + String(x) + ",\"y\":" + String(y);
json += ",\"joy\":" + String(digitalRead(JOY_SW_PIN) == LOW ? "true" : "false") + "}";
return json;
}
const char INDEX_HTML[] PROGMEM = R"SNAKE(
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>ESP32 Snake</title><style>*{box-sizing:border-box}body{margin:0;min-height:100vh;background:#09120d;color:#e9ffe9;font-family:Arial,sans-serif;display:grid;place-items:center}main{width:min(94vw,460px);text-align:center;padding:18px}h1{margin:0 0 7px;color:#76f58b;font-size:28px}p{margin:6px;color:#b8cfbb;font-size:14px}canvas{width:100%;max-width:420px;aspect-ratio:1;border:4px solid #4ccd69;background:#101d13;image-rendering:pixelated;margin:12px 0 8px}.bar{display:flex;justify-content:space-between;gap:8px;font-weight:bold}.hint{min-height:20px;color:#ffdb70}.offline{color:#ff8585}</style></head><body><main><h1>ESP32 Snake</h1><p>Joystick: steer • press joystick: play again</p><canvas id="game" width="420" height="420"></canvas><div class="bar"><span id="score">Score: 0</span><span id="connection">Controller connecting...</span></div><p class="hint" id="message">Press the joystick to begin</p></main><script>const canvas=document.getElementById('game'),ctx=canvas.getContext('2d'),scoreEl=document.getElementById('score'),messageEl=document.getElementById('message'),connectionEl=document.getElementById('connection');let audioContext;const enableAudio=()=>{audioContext=audioContext||new (window.AudioContext||window.webkitAudioContext)();audioContext.resume()};const beep=(frequency,duration,type='square',volume=.035)=>{if(!audioContext||audioContext.state!=='running')return;const oscillator=audioContext.createOscillator(),gain=audioContext.createGain();oscillator.type=type;oscillator.frequency.value=frequency;gain.gain.setValueAtTime(volume,audioContext.currentTime);gain.gain.exponentialRampToValueAtTime(.001,audioContext.currentTime+duration);oscillator.connect(gain);gain.connect(audioContext.destination);oscillator.start();oscillator.stop(audioContext.currentTime+duration)};document.addEventListener('pointerdown',enableAudio,{once:true});document.addEventListener('keydown',enableAudio,{once:true});const cells=21,size=20;let snake=[],food={x:0,y:0},dx=1,dy=0,nextDx=1,nextDy=0,score=0,running=false,lastStep=0,lastPoll=0,lastStart=false;const draw=()=>{ctx.fillStyle='#101d13';ctx.fillRect(0,0,420,420);ctx.strokeStyle='#1d3621';for(let i=0;i<=cells;i++){ctx.beginPath();ctx.moveTo(i*size,0);ctx.lineTo(i*size,420);ctx.moveTo(0,i*size);ctx.lineTo(420,i*size);ctx.stroke()}ctx.fillStyle='#ff5f62';ctx.fillRect(food.x*size+3,food.y*size+3,size-6,size-6);snake.forEach((p,i)=>{ctx.fillStyle=i?'#67df76':'#b9ffc2';ctx.fillRect(p.x*size+2,p.y*size+2,size-4,size-4)});if(!running){ctx.fillStyle='rgba(0,0,0,.48)';ctx.fillRect(0,0,420,420);ctx.fillStyle='#e9ffe9';ctx.font='bold 25px Arial';ctx.textAlign='center';ctx.fillText(score?'GAME OVER':'SNAKE',210,195);ctx.font='16px Arial';ctx.fillText(score?'Press joystick':'Press joystick to play',210,224)}};const placeFood=()=>{do{food={x:Math.floor(Math.random()*cells),y:Math.floor(Math.random()*cells)}}while(snake.some(p=>p.x===food.x&&p.y===food.y))};const reset=()=>{snake=[{x:10,y:10},{x:9,y:10},{x:8,y:10}];dx=1;dy=0;nextDx=1;nextDy=0;score=0;running=true;placeFood();scoreEl.textContent='Score: 0';messageEl.textContent='';beep(440,.07,'square');setTimeout(()=>beep(660,.09,'square'),80);draw()};const setDirection=(x,y)=>{if(x&&dx===0){nextDx=x;nextDy=0}else if(y&&dy===0){nextDx=0;nextDy=y}};const step=()=>{dx=nextDx;dy=nextDy;const h={x:snake[0].x+dx,y:snake[0].y+dy};if(h.x<0||h.x>=cells||h.y<0||h.y>=cells||snake.some(p=>p.x===h.x&&p.y===h.y)){running=false;messageEl.textContent='Game over - press joystick';beep(220,.16,'sawtooth');setTimeout(()=>beep(120,.28,'sawtooth'),150);draw();return}snake.unshift(h);if(h.x===food.x&&h.y===food.y){score++;scoreEl.textContent='Score: '+score;beep(880,.06,'square');placeFood()}else snake.pop();draw()};const poll=async()=>{try{const r=await fetch('/state',{cache:'no-store'});if(!r.ok)throw Error();const s=await r.json();connectionEl.textContent='Controller connected';connectionEl.className='';if(Math.abs(s.x)>Math.abs(s.y))setDirection(s.x>35?1:s.x<-35?-1:0,0);else setDirection(0,s.y>35?-1:s.y<-35?1:0);const start=s.joy;if(start&&!lastStart&&!running)reset();lastStart=start}catch(e){connectionEl.textContent='Controller reconnecting...';connectionEl.className='offline'}};const loop=now=>{if(now-lastPoll>=50){lastPoll=now;poll()}if(running&&now-lastStep>=145){lastStep=now;step()}requestAnimationFrame(loop)};draw();requestAnimationFrame(loop);</script></body></html>
)SNAKE";
void setup() {
Serial.begin(115200);
analogReadResolution(12);
pinMode(JOY_SW_PIN, INPUT_PULLUP);
WiFi.mode(WIFI_AP);
WiFi.softAP(AP_SSID, AP_PASSWORD);
server.on("/", HTTP_GET, []() { server.send_P(200, "text/html", INDEX_HTML); });
server.on("/state", HTTP_GET, []() { server.send(200, "application/json", controllerJson()); });
server.onNotFound([]() { server.send(404, "text/plain", "Use / or /state"); });
server.begin();
}
void loop() { server.handleClient(); }“Deploy to device” opens this project in Schematik, where you can flash it to your board over USB.
Remix this project
Make it yours in one click
Open a full copy of this project in your own Schematik workspace — diagram, code, parts, and assembly steps included. Swap the sensor, add features, or redesign the whole thing with AI. The author's original stays untouched.