Community project

ESP32 WiFi Game Controller

Arduino

Published August 17, 2026

ESP32
Photo of ESP32 WiFi Game ControllerGenerated with AI

Build 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
Wiring diagram for ESP32 WiFi Game Controller

Pan and zoom to explore the wiring. Remix the project to edit it in your own workspace.

Parts list

Bill of materials
ComponentQtyNotes
KY-023 Dual Axis Joystick Module3.3 V1Dual-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 steps
  1. Power 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.
  2. 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.
  3. 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.
  4. 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
PinConnectionType
3V3joystick_1 VCCpower
GNDjoystick_1 GNDground
GPIO 34joystick_1 VRxadc
GPIO 35joystick_1 VRyadc
GPIO 27joystick_1 SWdigital

Firmware

ESP32
main.cppDeploy to device
#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 &bull; 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.

Open in Schematik