Community project

ESP32 Mini Arcade Deck

Photo of ESP32 Mini Arcade Deck
Generated with AI

diego.molina.r

Published November 13, 2026

Build a pocket-sized arcade machine powered by an ESP32 microcontroller. This project combines a 1.8-inch ST7735 TFT display, a PS2 joystick module, and push buttons to create a playable gaming device that fits in a small handheld case. Nine classic arcade games are included: Tetris, Breakout, Flappy Bird, Pong, Dino Runner, Snake, Phoenix, Exerion, and Rally X.

This guide provides a complete wiring diagram showing how to connect the display via SPI, wire the joystick analog inputs, and integrate the control buttons. You'll get a full parts list, step-by-step assembly instructions for fitting everything into an enclosure, and ready-to-upload firmware with all nine games pre-loaded and high-score persistence.

Wiring diagram

Wiring diagram for ESP32 Mini Arcade Deck
Loading diagram…

Gather all the parts

QtyComponent
1

ST7735 1.8 inch TFT SPI Display

1.8 inch SPI TFT colour display with ST7735 controller, 128x160 resolution, 3.3V logic. Communicates over hardware SPI with MOSI, SCK, CS, DC, and RST control lines. Modules typically include an onboard regulator and level shifting accepting 3.3V or 5V VCC; logic is 3.3V. MISO is optional for write-only display use.

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

Push Button

Momentary push button switch

1

Push Button

Momentary push button switch

Assemble it in 4 steps

1. Connect the portrait screen

With USB unplugged, connect VCC and BLK to 3V3 and GND to GND. SCK to GPIO18 carries the clock; MOSI to GPIO23 carries pixels. CS to GPIO27 selects the screen, DC to GPIO26 selects commands, and RST to GPIO25 resets it. Leave MISO unconnected. Mount the 128 by 160 screen vertically.

  • Use a complete ST7735 module with onboard backlight current limiting, not a bare LCD panel.

2. Connect the thumb joystick

Connect the pin labelled +5V to 3V3, GND to GND, VRX to GPIO32 for left/right, and VRY to GPIO33 for up/down. Leave SW unconnected.

  • Do not supply this joystick with 5 V: its outputs could damage the ESP32.

3. Connect the two buttons

Connect button A between GPIO21 and GND, and button B between GPIO22 and GND. A starts games or performs the action. B opens scores or returns to the menu. The firmware enables internal pull-ups. For four-legged switches, use legs on opposite switched sides.

4. Fit the controls in a small case

Place the joystick below the vertical screen, with both buttons beside it. Insulate all exposed connections and leave access to the ESP32 USB socket. This version uses USB power only.

Review all connections

1. Connections between "screen" and "ESP32"

FunctionscreenESP32
powerVCC3V3
groundGNDGND
spiSCKGPIO 18
spiMOSIGPIO 23
spiCSGPIO 27
digitalDCGPIO 26
digitalRSTGPIO 25
powerBLK3V3

2. Connections between "stick" and "ESP32"

FunctionstickESP32
power+5V3V3
groundGNDGND
analogVRXGPIO 32
analogVRYGPIO 33

3. Connections between "a" and "ESP32"

FunctionaESP32
digitalSIGNALGPIO 21
groundGNDGND

4. Connections between "b" and "ESP32"

FunctionbESP32
digitalSIGNALGPIO 22
groundGNDGND

Deploy the firmware

#include <Arduino.h>
#include <SPI.h>
#include <Adafruit_GFX.h>
#include <Adafruit_ST7735.h>
#include <WiFi.h>
#include <WebServer.h>
#include <Preferences.h>
#include <time.h>
Adafruit_ST7735 tft(27,26,25);
GFXcanvas16 c(128,160);
WebServer web(80); Preferences prefs;
const char* names[]={"Tetris","Breakout","Flappy Bird","Pong","Dino Runner","Snake","Phoenix","Exerion","Rally X style"};
uint16_t pal[]={0xf800,0x07e0,0x001f,0xffe0,0xf81f,0x07ff,0xfd20};
int hi[9]={},sel=0,mode=0,score=0,tick=0,px=60,py=130,vx=2,vy=-2,bx=60,by=90,foodx=8,foody=8,len=4,dx=1,dy=0;
int sx[300],sy[300],grid[20][10],piece=0,rot=0,tx=3,ty=0; bool bricks[30],enemy[18];
int obsX=128,gap=70,jump=0,jv=0,bulletX=-1,bulletY=0,fuel=100,flags=0;
uint32_t last=0,active=0,nav=0; bool prevA=false,prevB=false,over=false,dirty=true; String ssid,pass,tz,ntp;
const uint16_t shapes[7][4]={{0x0F00,0x2222,0x0F00,0x2222},{0x6600,0x6600,0x6600,0x6600},{0x4E00,0x4640,0x0E40,0x4C40},{0x6C00,0x4620,0x6C00,0x4620},{0xC600,0x2640,0xC600,0x2640},{0x8E00,0x6440,0x0E20,0x44C0},{0x2E00,0x4460,0x0E80,0xC440}};
void text(int x,int y,String s,uint16_t color=0xffff,int size=1){c.setTextSize(size);c.setTextColor(color);c.setCursor(x,y);c.print(s);}
void finish(){over=true;if(score>hi[sel]){hi[sel]=score;prefs.putInt((String("h")+sel).c_str(),score);}dirty=true;}
void spawn(){piece=random(7);rot=0;tx=3;ty=0;}
bool fit(int x,int y,int r){uint16_t m=shapes[piece][r];for(int i=0;i<16;i++)if(m&(0x8000>>i)){int xx=x+i%4,yy=y+i/4;if(xx<0||xx>=10||yy>=20||(yy>=0&&grid[yy][xx]))return false;}return true;}
void start(){mode=1;score=tick=0;over=false;px=60;py=130;bx=60;by=90;vx=2;vy=-2;obsX=128;gap=random(45,110);jump=jv=0;len=4;dx=1;dy=0;fuel=100;flags=0;bulletX=-1;memset(grid,0,sizeof(grid));for(int i=0;i<30;i++)bricks[i]=true;for(int i=0;i<18;i++)enemy[i]=true;for(int i=0;i<len;i++){sx[i]=8-i;sy[i]=10;}foodx=random(16);foody=random(17);spawn();dirty=true;}
void game(int x,int y,bool a){if(over)return;tick++;dirty=true;
 if(sel==0){if(tick%3==0&&x&&fit(tx+x,ty,rot))tx+=x;if(a&&fit(tx,ty,(rot+1)%4))rot=(rot+1)%4;if(tick%(y>0?2:10)==0){if(fit(tx,ty+1,rot))ty++;else{uint16_t m=shapes[piece][rot];for(int i=0;i<16;i++)if(m&(0x8000>>i)){int yy=ty+i/4;if(yy<0){finish();return;}grid[yy][tx+i%4]=piece+1;}for(int row=19;row>=0;row--){bool full=true;for(int k=0;k<10;k++)if(!grid[row][k])full=false;if(full){for(int j=row;j>0;j--)for(int k=0;k<10;k++)grid[j][k]=grid[j-1][k];memset(grid[0],0,sizeof(grid[0]));score+=100;row++;}}spawn();if(!fit(tx,ty,rot))finish();}}}
 else if(sel==1||sel==3){px=constrain(px+x*5,0,100);bx+=vx;by+=vy;if(bx<3||bx>124){vx=-vx;bx=constrain(bx,3,124);}if(by>=140&&by<148&&bx>=px&&bx<=px+28){vy=-abs(vy);score+=1;}if(by>159)finish();if(sel==1){if(by<23)vy=abs(vy);for(int i=0;i<30;i++)if(bricks[i]&&bx>=4+(i%6)*20&&bx<22+(i%6)*20&&by>=28+(i/6)*9&&by<36+(i/6)*9){bricks[i]=false;vy=-vy;score+=10;break;}bool any=false;for(bool b:bricks)any|=b;if(!any){for(int i=0;i<30;i++)bricks[i]=true;}}else{py+=constrain(bx-py-14,-3,3);py=constrain(py,0,100);if(by<32){if(bx>=py&&bx<=py+28){vy=abs(vy);}else{score+=10;bx=64;by=70;vy=2;}}}}
 else if(sel==2){if(a)vy=-5;vy=min(vy+1,5);by+=vy;obsX-=3;if(obsX<-18){obsX=128;gap=random(50,110);score++;}if(by<24||by>155||(obsX<40&&obsX+18>26&&(by<gap-22||by>gap+22)))finish();}
 else if(sel==4){if(a&&jump==0)jv=10;if(jump||jv){jump+=jv;jv--;if(jump<0){jump=0;jv=0;}}obsX-=4;if(obsX<-12){obsX=128+random(40);score++;}if(obsX<38&&obsX+10>24&&jump<18)finish();}
 else if(sel==5){if(x&&dx==0){dx=x;dy=0;}else if(y&&dy==0){dy=y;dx=0;}if(tick%3==0){int nx=sx[0]+dx,ny=sy[0]+dy;if(nx<0||nx>=16||ny<0||ny>=17){finish();return;}bool eat=nx==foodx&&ny==foody;for(int i=0;i<len-(eat?0:1);i++)if(sx[i]==nx&&sy[i]==ny){finish();return;}if(eat){len=min(len+1,270);score+=10;do{foodx=random(16);foody=random(17);bool ok=true;for(int i=0;i<len-1;i++)if(sx[i]==foodx&&sy[i]==foody)ok=false;if(ok)break;}while(true);}for(int i=len-1;i>0;i--){sx[i]=sx[i-1];sy[i]=sy[i-1];}sx[0]=nx;sy[0]=ny;}}
 else if(sel==6||sel==7){px=constrain(px+x*4,5,118);if(sel==7)py=constrain(py+y*3,90,145);if(a&&bulletX<0){bulletX=px;bulletY=py-5;}if(bulletX>=0){bulletY-=7;if(bulletY<22)bulletX=-1;}int alive=0;for(int i=0;i<18;i++)if(enemy[i]){alive++;int ex=12+(i%6)*19+int(sin(tick*0.08)*(sel==7?8:4)),ey=30+(i/6)*15+(tick/20)%50;if(bulletX>=0&&abs(bulletX-ex)<8&&abs(bulletY-ey)<8){enemy[i]=false;bulletX=-1;score+=20;}if(abs(px-ex)<9&&abs(py-ey)<9)finish();}if(tick%15==0){bx=random(8,120);by=65;}by+=4;if(abs(px-bx)<7&&abs(py-by)<8)finish();if(!alive){for(int i=0;i<18;i++)enemy[i]=true;tick=0;}}
 else {px=constrain(px+x*4,15,112);py=constrain(py+y*4,32,145);obsX-=3;if(obsX<0){obsX=128;gap=random(35,145);fuel-=5;}if(a&&fuel>0){fuel--;obsX-=6;}if(abs(px-obsX)<12&&abs(py-gap)<12)finish();if(abs(px-foodx)<10&&abs(py-foody)<10){score+=50;flags++;foodx=random(20,110);foody=random(35,140);fuel=min(100,fuel+15);}if(fuel<=0)finish();}
}
void render(){c.fillScreen(0);if(mode==0){text(4,2,"CYBER DECK",0x07ff);for(int i=0;i<9;i++){if(i==sel)c.fillRect(0,16+i*11,128,11,0x0010);text(4,18+i*11,names[i],i==sel?0xffe0:pal[i%7]);}text(4,119,"HIGH SCORE: "+String(hi[sel]));text(4,136,"A:START B:SCORES");text(4,148,WiFi.status()==WL_CONNECTED?WiFi.localIP().toString():"AP:192.168.4.1");}
 else if(mode==2){text(4,3,"HIGH SCORES",0x07ff);for(int i=0;i<9;i++)text(2,22+i*12,String(names[i])+" "+hi[i],pal[i%7]);text(4,145,"B:BACK");}
 else if(mode==3){time_t now=time(nullptr);struct tm tm;localtime_r(&now,&tm);char buf[32];if(now>1700000000){strftime(buf,sizeof(buf),"%H:%M:%S",&tm);text(16,55,buf,0x07ff,2);strftime(buf,sizeof(buf),"%Y-%m-%d",&tm);text(30,90,buf,0xffe0);}else{text(12,60,"Waiting for NTP");}text(8,125,"A/B: game list");}
 else{ text(0,2,String(names[sel]),0x07ff);text(0,13,"Score "+String(score),0xffe0);
 if(sel==0){for(int yy=0;yy<20;yy++)for(int xx=0;xx<10;xx++)if(grid[yy][xx])c.fillRect(24+xx*8,24+yy*6,7,5,pal[grid[yy][xx]-1]);uint16_t m=shapes[piece][rot];for(int i=0;i<16;i++)if(m&(0x8000>>i))c.fillRect(24+(tx+i%4)*8,24+(ty+i/4)*6,7,5,pal[piece]);}
 if(sel==1||sel==3){c.fillRect(px,144,28,4,0x07e0);c.fillCircle(bx,by,3,0xffe0);if(sel==1)for(int i=0;i<30;i++){if(bricks[i])c.fillRect(4+(i%6)*20,28+(i/6)*9,18,7,pal[i%7]);}else c.fillRect(py,28,28,4,0xf81f);}
 if(sel==2){c.fillRect(obsX,24,18,max(0,gap-46),0x07e0);c.fillRect(obsX,gap+22,18,160-gap-22,0x07ff);c.fillCircle(32,by,5,0xffe0);}
 if(sel==4){c.drawFastHLine(0,149,128,0x07ff);c.fillRect(24,133-jump,12,16,0x07e0);c.fillRect(obsX,130,10,19,0xf800);}
 if(sel==5){c.fillRect(foodx*8,24+foody*8,7,7,0xf800);for(int i=0;i<len;i++)c.fillRect(sx[i]*8,24+sy[i]*8,7,7,pal[i%7]);}
 if(sel==6||sel==7){for(int i=0;i<18;i++)if(enemy[i])c.fillRect(7+(i%6)*19+int(sin(tick*0.08)*(sel==7?8:4)),26+(i/6)*15+(tick/20)%50,10,8,pal[i%7]);c.fillTriangle(px,py-6,px-6,py+6,px+6,py+6,0x07ff);if(bulletX>=0)c.fillRect(bulletX,bulletY,2,5,0xffe0);c.fillCircle(bx,by,3,0xf800);}
 if(sel==8){for(int i=0;i<5;i++)c.drawFastVLine(12+i*25,24,130,0x001f);c.fillRect(foodx,foody,6,8,0xffe0);c.fillRect(obsX,gap,10,12,0xf800);c.fillRect(px-4,py-6,8,12,0x07e0);text(0,150,"Fuel "+String(fuel),0xf81f);}
 if(over){c.fillRect(6,65,116,36,0x0010);text(18,72,"GAME OVER",0xf800);text(12,88,"A:RETRY B:MENU");}}
 tft.drawRGBBitmap(0,0,c.getBuffer(),128,160);dirty=false;}
String esc(String s){s.replace("&","&amp;");s.replace("<","&lt;");s.replace("\"","&quot;");return s;}
void syncTime(){configTzTime(tz.c_str(),ntp.c_str());}
void page(){String h="<!doctype html><meta name='viewport' content='width=device-width'><title>Cyber Deck</title><h1>Cyber Deck</h1>";if(WiFi.status()!=WL_CONNECTED)h+="<h2>Wi-Fi setup</h2><form method='post' action='/wifi'>Network name <input name='s' maxlength='32' required><br>Password <input type='password' name='p' maxlength='63'><button>Connect</button></form>";else h+="<p>Connected. Address: "+WiFi.localIP().toString()+"</p>";h+="<h2>Clock and gaming settings</h2><form method='post' action='/clock'>NTP server <input name='n' value='"+esc(ntp)+"'><br>Timezone / DST rule <input name='t' value='"+esc(tz)+"'><p>UTC: UTC0. US Eastern: EST5EDT,M3.2.0/2,M11.1.0/2. Central Europe: CET-1CEST,M3.5.0/2,M10.5.0/3. These rules automatically apply daylight saving.</p><button>Save and sync</button></form><form method='post' action='/colors'><button>Randomize colors</button></form><form method='post' action='/clear' onsubmit='return confirm(\"Erase all scores?\")'><button>Clear high scores</button></form><h2>Bluetooth controllers</h2><p>Not supported in this firmware. Use the wired joystick and buttons. Full gamepad pairing requires the Bluepad32 framework.</p><h2>High scores</h2>";for(int i=0;i<9;i++)h+=String(names[i])+": "+hi[i]+"<br>";web.send(200,"text/html",h);}
void redirect(){web.sendHeader("Location","/");web.send(303);dirty=true;}
void setup(){Serial.begin(115200);pinMode(21,INPUT_PULLUP);pinMode(22,INPUT_PULLUP);SPI.begin(18,19,23);tft.initR(INITR_BLACKTAB);tft.setRotation(0);c.setTextWrap(false);prefs.begin("deck",false);for(int i=0;i<9;i++)hi[i]=prefs.getInt((String("h")+i).c_str(),0);ssid=prefs.getString("ssid","");pass=prefs.getString("pass","");tz=prefs.getString("tz","UTC0");ntp=prefs.getString("ntp","pool.ntp.org");WiFi.mode(WIFI_AP_STA);WiFi.softAP("CyberDeck-Setup","PlayDeck2026");if(ssid.length())WiFi.begin(ssid.c_str(),pass.c_str());syncTime();web.on("/",HTTP_GET,page);web.on("/wifi",HTTP_POST,[]{ssid=web.arg("s");pass=web.arg("p");if(ssid.length()>32||!ssid.length()||(pass.length()&&pass.length()<8)){web.send(400,"text/plain","Check network name and password");return;}prefs.putString("ssid",ssid);prefs.putString("pass",pass);WiFi.begin(ssid.c_str(),pass.c_str());redirect();});web.on("/clock",HTTP_POST,[]{if(web.arg("t").length()>100||web.arg("n").length()>100){web.send(400);return;}tz=web.arg("t");ntp=web.arg("n");prefs.putString("tz",tz);prefs.putString("ntp",ntp);syncTime();redirect();});web.on("/colors",HTTP_POST,[]{for(int i=0;i<7;i++)pal[i]=random(0x2000,0xffff);redirect();});web.on("/clear",HTTP_POST,[]{for(int i=0;i<9;i++){hi[i]=0;prefs.putInt((String("h")+i).c_str(),0);}redirect();});web.begin();active=millis();render();}
void loop(){web.handleClient();uint32_t now=millis();if(now-last<50)return;last=now;int rx=analogRead(32),ry=analogRead(33);int x=rx<1100?-1:rx>3000?1:0,y=ry<1100?-1:ry>3000?1:0;bool aa=!digitalRead(21),bb=!digitalRead(22),a=aa&&!prevA,b=bb&&!prevB;prevA=aa;prevB=bb;if(x||y||aa||bb)active=now;
 if(mode==3){if(a||b||x||y){mode=0;dirty=true;}else{static time_t old=0;time_t n=time(nullptr);if(n!=old){old=n;dirty=true;}}}
 else if(mode==0){if(y&&now-nav>180){sel=(sel+y+9)%9;nav=now;dirty=true;}if(a)start();if(b){mode=2;dirty=true;}if(now-active>30000){mode=3;dirty=true;}}
 else if(mode==2){if(b){mode=0;dirty=true;}}
 else{if(b){mode=0;dirty=true;}else if(over){if(a)start();}else game(x,y,a);}
 if(dirty)render();}

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