Community project

ESP32 Game Boy Console

Photo of ESP32 Game Boy Console
Generated with AI

JOSH VAL RIVEN BAUSO

Published November 12, 2026

This project transforms an ESP32 microcontroller into a playable handheld arcade console featuring seven classic games: Snake, Pong, Breakout, Dodge, Racing, Catch, and Flap. The build combines an ILI9341 TFT touchscreen for vibrant graphics, a PS2 joystick module for intuitive controls, and minimal additional components to create a fully functional gaming device.

The guide provides a complete wiring diagram showing how to connect the touchscreen and joystick to the ESP32, a full parts list with component specifications, ready-to-upload firmware with all seven games pre-programmed, and step-by-step assembly instructions including critical safety notes about disconnecting power before modifications and protecting the display backlight.

Wiring diagram

Wiring diagram for ESP32 Game Boy Console
Loading diagram…

Gather all the parts

QtyComponent
1

ILI9341 TFT Touchscreen

240x320 SPI TFT display using the ILI9341 LCD controller with an XPT2046 resistive touch controller sharing the SPI bus

1

PS2 Joystick Module (KY-023)

Two-axis analog thumb joystick module (KY-023 / PS2 style) with a push switch under the stick. Two 10 kOhm potentiometers give X and Y voltages that sit near half the supply at rest, and pressing the stick operates a digital switch. It runs from 3.3 to 5 V and connects through a five-pin GND, +5V, VRx, VRy, SW right-angle header.

1

Resistor

100 ohm, 1/4 W

Through-hole resistor (current-limiting in series with an LED)

Assemble it in 5 steps

1. Disconnect USB before changing wires

Use an ESP32 DevKit v1 and your existing ILI9341 screen and joystick. This console is powered by USB, with no battery.

2. Move two screen control wires

Move screen CS from GPIO15 to GPIO21 (selects the screen). Move DC from GPIO2 to GPIO22 (selects commands or picture data). Keep RST on GPIO4, MOSI on GPIO23 (data), SCK on GPIO18 (clock), and MISO on GPIO19 (return data).

  • GPIO2 and GPIO15 are startup configuration pins; this project avoids them to reduce startup trouble.

3. Protect the backlight

Remove the direct LED-to-3.3V wire. Connect screen LED through the 100 ohm resistor to 3.3V. The resistor can face either way. Screen LED is called TFT_BL in the diagram. Keep VCC on 3.3V and GND on GND.

  • The resistor limits backlight current; the screen may be dimmer than with a direct wire.
  • If your screen has a touch T_CS pin, connect it to 3.3V to keep unused touch deselected. Leave T_IRQ and other touch pins disconnected. Touch is not used.

4. Check the joystick wiring

Keep VRX on GPIO34 (left/right), VRY on GPIO35 (up/down), and SW on GPIO27 (click). Keep VCC on 3.3V and GND on GND. The module's supply pin may be marked +5V but must use 3.3V here.

  • Do not power the joystick from 5V: its outputs could damage the ESP32 inputs.

5. Prepare for first power

Check for loose strands and swapped power wires. Set the screen in landscape orientation. Leave the joystick centered when plugging in USB so the console can measure its resting position.

Review all connections

1. Connections between "screen" and "ESP32"

FunctionscreenESP32
powerVCC3V3
groundGNDGND
digitalTFT_CSGPIO 21
digitalTFT_RSTGPIO 4
digitalTFT_DCGPIO 22
spiMOSIGPIO 23
spiSCKGPIO 18
spiMISOGPIO 19
digitalTFT_BL → Resistor P1EXT
powerTOUCH_CS3V3

2. Connections between "backlight_resistor" and "ESP32"

Functionbacklight_resistorESP32
powerP23V3

3. Connections between "stick" and "ESP32"

FunctionstickESP32
analogVRXGPIO 34
analogVRYGPIO 35
digitalSWGPIO 27
power+5V3V3
groundGNDGND

Deploy the firmware

#include <Arduino.h>
#include <SPI.h>
#include <Adafruit_GFX.h>
#include <Adafruit_ILI9341.h>
Adafruit_ILI9341 tft(21,22,4);
constexpr uint16_t BG=0xD6B0, INK=0x1945;
constexpr int X_PIN=34,Y_PIN=35,SW_PIN=27;
int cx=2048,cy=2048,mode=0,choice=0,score=0;
int sx[600],sy[600],len,dx,dy,fx,fy;
int paddle=90,enemy=90,bx=160,by=110,vx=3,vy=2,misses=0;
uint32_t tick=0,nav=0,pressedAt=0,changed=0;
bool rawPrev=false,stable=false,longDone=false;
void text(int x,int y,const char *s,int size=2){tft.setCursor(x,y);tft.setTextSize(size);tft.setTextColor(INK,BG);tft.print(s);}
const char* titles[]={"SNAKE","PONG","BREAKOUT","DODGE","RACING","CATCH","FLAP"};
void hud(){tft.fillRect(0,0,320,24,BG);text(8,4,titles[mode-1]);tft.setCursor(180,4);tft.print(score);}
void menu(){mode=0;tft.fillScreen(BG);text(24,10,"POCKET ARCADE",3);for(int i=0;i<7;i++){text(35,46+i*22,i==choice?">":" ");text(60,46+i*22,titles[i]);}text(14,208,"Move: select  Click: play",1);text(14,226,"Hold click in game = menu",1);}
void food(){bool hit;do{fx=random(30);fy=random(18);hit=false;for(int i=0;i<len;i++)if(sx[i]==fx&&sy[i]==fy)hit=true;}while(hit);tft.fillRect(10+fx*10,32+fy*10,8,8,INK);}
void endGame(){mode=99;text(65,90,"GAME OVER",3);text(42,142,"Click for menu",2);}
void extraStart();
void extraUpdate(int x,int y,bool click);
void start(){score=0;tick=millis();tft.fillScreen(BG);mode=choice+1;hud();tft.drawRect(8,30,304,184,INK);if(mode==1){len=3;dx=1;dy=0;for(int i=0;i<len;i++){sx[i]=10-i;sy[i]=9;tft.fillRect(10+sx[i]*10,32+sy[i]*10,8,8,INK);}food();}else if(mode==2){paddle=90;enemy=90;bx=160;by=110;vx=3;vy=2;misses=0;tft.fillRect(16,paddle,5,36,INK);tft.fillRect(299,enemy,5,36,INK);tft.fillRect(bx,by,5,5,INK);}else{extraStart();}text(30,226,"Hold joystick button to exit",1);}
void snake(int x,int y){int ndx=dx,ndy=dy;if(abs(x)>abs(y)&&abs(x)>650){ndx=x>0?1:-1;ndy=0;}else if(abs(y)>650){ndx=0;ndy=y>0?1:-1;}if(ndx!=-dx||ndy!=-dy){dx=ndx;dy=ndy;}
if(millis()-tick<150)return;tick=millis();int nx=sx[0]+dx,ny=sy[0]+dy;bool eat=nx==fx&&ny==fy;
if(nx<0||nx>=30||ny<0||ny>=18){endGame();return;}for(int i=0;i<len-(eat?0:1);i++)if(nx==sx[i]&&ny==sy[i]){endGame();return;}
if(!eat)tft.fillRect(10+sx[len-1]*10,32+sy[len-1]*10,8,8,BG);else len++;
for(int i=len-1;i>0;i--){sx[i]=sx[i-1];sy[i]=sy[i-1];}sx[0]=nx;sy[0]=ny;tft.fillRect(10+nx*10,32+ny*10,8,8,INK);
if(eat){score++;hud();if(len==540){endGame();return;}food();}}
void pong(int y){if(millis()-tick<25)return;tick=millis();int old=paddle,oe=enemy;int move=abs(y)>450?y/350:0;paddle=constrain(paddle+move,32,176);enemy=constrain(enemy+((by>enemy+18)?2:-2),32,176);
tft.fillRect(16,old,5,36,BG);tft.fillRect(299,oe,5,36,BG);tft.fillRect(bx,by,5,5,BG);bx+=vx;by+=vy;if(by<=32||by>=207){vy=-vy;by=constrain(by,32,207);}
if(vx<0&&bx<=21&&bx>=15&&by+5>=paddle&&by<=paddle+36){vx=3;score++;hud();}if(vx>0&&bx+5>=299&&bx<=304&&by+5>=enemy&&by<=enemy+36)vx=-3;
if(bx<10||bx>306){if(bx<10)misses++;bx=160;by=110;vx=bx%2?3:-3;if(misses>=3){endGame();return;}}
tft.fillRect(16,paddle,5,36,INK);tft.fillRect(299,enemy,5,36,INK);tft.fillRect(bx,by,5,5,INK);}
// Shared state for the five additional games. Only changed objects are redrawn.
int px=150,py=110,ox=100,oy=35,speed=3,gate=100;
bool bricks[24];
bool overlap(int x,int y,int w,int h,int a,int b,int c,int d){return x<a+c&&x+w>a&&y<b+d&&y+h>b;}
void extraStart(){
 px=150;py=110;ox=random(24,280);oy=35;speed=3;gate=random(65,175);
 if(mode==3){bx=155;by=170;vx=3;vy=-3;for(int i=0;i<24;i++){bricks[i]=true;tft.fillRect(16+(i%8)*36,40+(i/8)*15,32,11,INK);}tft.fillRect(px,197,48,6,INK);tft.fillRect(bx,by,5,5,INK);}
 if(mode==4||mode==6){tft.fillRect(px,190,mode==6?38:14,10,INK);tft.fillRect(ox,oy,12,12,INK);}
 if(mode==5){px=1;ox=random(3);tft.drawFastVLine(110,32,180,INK);tft.drawFastVLine(210,32,180,INK);tft.fillRect(53+px*100,186,18,20,INK);tft.fillRect(53+ox*100,oy,18,22,INK);}
 if(mode==7){ox=270;py=110;vy=0;tft.fillRect(65,py,10,10,INK);tft.fillRect(ox,32,16,gate-32,INK);tft.fillRect(ox,gate+56,16,212-gate-56,INK);}
}
void extraUpdate(int x,int y,bool click){
 if(mode==7&&click)vy=-6;
 if(millis()-tick<35)return;tick=millis();
 if(mode==3){
  int old=px;px=constrain(px+(abs(x)>450?x/220:0),12,258);
  if(old!=px){tft.fillRect(old,197,48,6,BG);tft.fillRect(px,197,48,6,INK);}
  tft.fillRect(bx,by,5,5,BG);bx+=vx;by+=vy;
  if(bx<=11||bx>=303){vx=-vx;bx=constrain(bx,11,303);}if(by<=32){vy=abs(vy);by=32;}
  if(vy>0&&overlap(bx,by,5,5,px,197,48,6)){vy=-3;by=192;}
  for(int i=0;i<24;i++)if(bricks[i]&&overlap(bx,by,5,5,16+i%8*36,40+i/8*15,32,11)){bricks[i]=false;tft.fillRect(16+i%8*36,40+i/8*15,32,11,BG);vy=-vy;score++;hud();break;}
  if(by>207||score==24){endGame();return;}tft.fillRect(bx,by,5,5,INK);
 }else if(mode==4||mode==6){
  int width=mode==6?38:14,old=px;px=constrain(px+(abs(x)>450?x/220:0),12,308-width);
  if(old!=px){tft.fillRect(old,190,width,10,BG);tft.fillRect(px,190,width,10,INK);}
  tft.fillRect(ox,oy,12,12,BG);oy+=3+score/8;
  if(overlap(px,190,width,10,ox,oy,12,12)){if(mode==4){endGame();return;}score++;hud();oy=35;ox=random(14,294);}
  if(oy>200){if(mode==6){endGame();return;}score++;hud();oy=35;ox=random(14,294);}
  tft.fillRect(ox,oy,12,12,INK);
 }else if(mode==5){
  int old=px;if(abs(x)>650&&millis()-nav>180){px=constrain(px+(x>0?1:-1),0,2);nav=millis();}
  if(old!=px){tft.fillRect(53+old*100,186,18,20,BG);tft.fillRect(53+px*100,186,18,20,INK);}
  tft.fillRect(53+ox*100,oy,18,22,BG);oy+=4+score/6;
  if(px==ox&&oy+22>=186&&oy<=206){endGame();return;}
  if(oy>208){oy=35;ox=random(3);score++;hud();}tft.fillRect(53+ox*100,oy,18,22,INK);
 }else if(mode==7){
  tft.fillRect(65,py,10,10,BG);tft.fillRect(ox,32,16,gate-32,BG);tft.fillRect(ox,gate+56,16,212-gate-56,BG);
  vy=constrain(vy+1,-6,7);py+=vy;ox-=3;
  if(py<32||py+10>212||(ox<75&&ox+16>65&&(py<gate||py+10>gate+56))){endGame();return;}
  if(ox+16<10){ox=288;gate=random(60,151);score++;hud();}
  tft.fillRect(ox,32,16,gate-32,INK);tft.fillRect(ox,gate+56,16,212-gate-56,INK);tft.fillRect(65,py,10,10,INK);
 }
}
void setup(){Serial.begin(115200);pinMode(SW_PIN,INPUT_PULLUP);analogReadResolution(12);SPI.begin(18,19,23,21);tft.begin(20000000);tft.setRotation(1);tft.fillScreen(BG);text(16,80,"Keep joystick centered",2);text(16,115,"Calibrating...",2);delay(600);long ax=0,ay=0;for(int i=0;i<64;i++){ax+=analogRead(X_PIN);ay+=analogRead(Y_PIN);delay(4);}cx=ax/64;cy=ay/64;randomSeed(micros());menu();}
void loop(){bool click=false;uint32_t now=millis();bool raw=digitalRead(SW_PIN)==LOW;if(raw!=rawPrev){changed=now;rawPrev=raw;}if(raw!=stable&&now-changed>30){stable=raw;if(stable){pressedAt=now;longDone=false;}else if(!longDone){if(mode==0)start();else if(mode==99)menu();else click=true;}}
if(stable&&!longDone&&now-pressedAt>800){longDone=true;menu();}
int x=analogRead(X_PIN)-cx,y=analogRead(Y_PIN)-cy;
if(mode==0&&abs(y)>650&&now-nav>230){choice=(choice+(y>0?1:6))%7;nav=now;menu();}else if(mode==1)snake(x,y);else if(mode==2)pong(y);else if(mode>=3&&mode<=7)extraUpdate(x,y,click);delay(2);}

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