Hand-built handheld game console with TFT screen, joystick, and exposed ESP32-S3 wiring

Advanced

Build a Retro Handheld Game Console

DisplaysSensors

A genuinely new category for this store: a small handheld with a real color screen and real controls, running simple original games you write yourself, the same fundamentals behind every classic handheld.

4 real parts included

Total for this kit: 1,271.00 ฿

Doesn't include any order discount you may get at checkout.

What you'll need

Project Goal

This is the most involved build in the catalog: a real handheld game device with a color screen, a physical control stick, and sound, running games you write entirely yourself. It pulls together display graphics, analog input, and audio into one project, the same three ingredients behind every classic handheld console.

What the Controller Does

It runs a continuous game loop: read the joystick, update game state based on that input, redraw the screen, check for a win or loss condition, and repeat, dozens of times per second. Everything a game actually is comes down to that one loop running fast and consistently.

What the Display Does

The color TFT redraws the game's visuals every single frame: player position, score, obstacles, whatever the game needs shown. Full color opens up genuinely game-like visuals that a small monochrome OLED can't match.

What the Joystick Does

It reports its physical position as two simple analog values plus a digital button click, the exact same input style behind a real game controller, giving the controller everything it needs to move a player character with real, gradual control rather than just on/off directions.

What the Buzzer Does

It supplies simple sound effects and beeps on key game events, scoring, collisions, game over, the small audio cues that make a game feel genuinely responsive.

Software and Library Concepts

Start far simpler than a full game: draw one static shape on screen, then make it move in response to the joystick, then add a second object and simple collision detection. A complete game is just that same loop with more rules layered on top. Start with something as simple as a single moving dot before attempting anything with real graphics.

Testing and Troubleshooting

  • Redraws look flickery or slow, avoid clearing and redrawing the entire screen every frame, only redraw the specific pixels that actually changed.
  • The joystick reads jumpy or never quite returns to center, read its resting values once at startup and treat that as zero, rather than assuming it is a perfect midpoint.
  • This is a lower-volume component listing than most of the catalog, if you notice anything off about the joystick out of the box, it is worth a quick message to the supplier rather than assuming it is normal.

Optional Upgrade

Add the SD card module from the Data Logger build to save high scores that survive a power cycle, a real save file instead of a score that resets every time.

Wiring

  • ST7789 VCC → ESP32-S3 3.3V
  • ST7789 GND → ESP32-S3 GND
  • ST7789 SCK → ESP32-S3 GPIO12
  • ST7789 SDA (MOSI) → ESP32-S3 GPIO11
  • ST7789 RES (RST) → ESP32-S3 GPIO8
  • ST7789 DC → ESP32-S3 GPIO9
  • ST7789 CS → ESP32-S3 GPIO10
  • ST7789 BLK → ESP32-S3 GPIO13, or tie directly to 3.3V for an always-on backlight
  • Joystick VRx → ESP32-S3 GPIO1 (ADC)
  • Joystick VRy → ESP32-S3 GPIO2 (ADC)
  • Joystick SW → ESP32-S3 GPIO3 (INPUT_PULLUP, active LOW)
  • Joystick VCC/GND → ESP32-S3 3.3V/GND
  • Buzzer signal → ESP32-S3 GPIO4
  • Buzzer GND → ESP32-S3 GND

2.4" ST7789 panels are commonly 240x320 but vary by supplier — check yours and adjust `SCREEN_WIDTH`/`SCREEN_HEIGHT` and `setRotation()` if the image is mirrored or cropped. This is an active buzzer per the parts list, so it can only click on/off — fine for boundary-hit beeps, but it cannot play a tune or vary pitch (see the Theremin project for tone generation, which needs a passive buzzer instead).

Wiring Diagram

VCC → 3.3V GND SCK → GPIO12 SDA → GPIO11 RES → GPIO8 DC → GPIO9 CS → GPIO10 BLK → GPIO13 GPIO1 GPIO2 SW → GPIO3 VCC/GND → 3.3V/GND signal → GPIO4 GND ESP32-S3 ST7789 Joystick VRx Joystick VRy Joystick Buzzer

Starter Code

#include <SPI.h>
#include <Adafruit_GFX.h>
#include <Adafruit_ST7789.h>

// --- Pin definitions: adjust to match your ESP32-S3 board's silkscreen if different ---
#define TFT_CS   10
#define TFT_DC   9
#define TFT_RST  8
#define TFT_SCK  12
#define TFT_MOSI 11
#define TFT_BLK  13   // backlight - tie to 3.3V directly, or drive as PWM for dimming

#define VRX_PIN  1    // joystick X (ADC-capable pin)
#define VRY_PIN  2    // joystick Y (ADC-capable pin)
#define SW_PIN   3    // joystick push button, active LOW
#define BUZZER_PIN 4  // active buzzer signal

// 2.4" ST7789 panels are commonly 240x320 - adjust if yours differs
#define SCREEN_WIDTH  240
#define SCREEN_HEIGHT 320

SPIClass tftSPI(FSPI);
Adafruit_ST7789 tft = Adafruit_ST7789(&tftSPI, TFT_CS, TFT_DC, TFT_RST);

int paddleX, paddleY;
const int PADDLE_SIZE = 20;
const int MOVE_SPEED = 4;

void beep(int durationMs) {
  digitalWrite(BUZZER_PIN, HIGH);
  delay(durationMs);
  digitalWrite(BUZZER_PIN, LOW);
}

void setup() {
  Serial.begin(115200);
  pinMode(SW_PIN, INPUT_PULLUP);
  pinMode(BUZZER_PIN, OUTPUT);
  digitalWrite(BUZZER_PIN, LOW);

  pinMode(TFT_BLK, OUTPUT);
  digitalWrite(TFT_BLK, HIGH); // backlight on

  tftSPI.begin(TFT_SCK, -1, TFT_MOSI, TFT_CS);
  tft.init(SCREEN_WIDTH, SCREEN_HEIGHT);
  tft.setRotation(2);
  tft.fillScreen(ST77XX_BLACK);

  paddleX = SCREEN_WIDTH / 2;
  paddleY = SCREEN_HEIGHT / 2;
}

void loop() {
  int rawX = analogRead(VRX_PIN); // 0-4095 on ESP32 ADC
  int rawY = analogRead(VRY_PIN);

  int dx = map(rawX, 0, 4095, -MOVE_SPEED, MOVE_SPEED);
  int dy = map(rawY, 0, 4095, -MOVE_SPEED, MOVE_SPEED);

  // erase old paddle
  tft.fillRect(paddleX, paddleY, PADDLE_SIZE, PADDLE_SIZE, ST77XX_BLACK);

  paddleX += dx;
  paddleY += dy;

  bool hitEdge = false;
  if (paddleX < 0) { paddleX = 0; hitEdge = true; }
  if (paddleX > SCREEN_WIDTH - PADDLE_SIZE) { paddleX = SCREEN_WIDTH - PADDLE_SIZE; hitEdge = true; }
  if (paddleY < 0) { paddleY = 0; hitEdge = true; }
  if (paddleY > SCREEN_HEIGHT - PADDLE_SIZE) { paddleY = SCREEN_HEIGHT - PADDLE_SIZE; hitEdge = true; }

  if (hitEdge) beep(40);

  // draw new paddle
  tft.fillRect(paddleX, paddleY, PADDLE_SIZE, PADDLE_SIZE, ST77XX_GREEN);

  if (digitalRead(SW_PIN) == LOW) {
    beep(100); // button press feedback
  }

  delay(20);
}

Not sure about a step? Ask us