Community project
Touchscreen Game Phone
Build a handheld game device powered by an ESP32 microcontroller and a 3.5-inch touchscreen display. This project combines the ILI9488 SPI TFT display with capacitive touch sensing to create an interactive gaming platform featuring multiple games accessible through an intuitive menu interface.
This guide provides a complete wiring diagram, parts list, and step-by-step assembly instructions to get the touchscreen game phone operational. The included firmware handles display rendering, touch input detection, and game logic, so builders can upload the code and start playing immediately after assembly.
Wiring diagram

Gather all the parts
Assemble it in 5 steps
1. Keep the power unplugged
Unplug the USB cable before moving any wire. Work with the NodeMCU and screen unpowered so an accidental short does not damage them.
- Follow the words printed beside each screen pin, not the physical order of the header pins.
- Use 3V3 only for the screen VCC and LED pins — connecting either one to 5V or VIN can damage the screen.
2. Wire the display
Connect VCC → 3V3 (power), GND → GND (ground), LED → 3V3 (backlight power), SCK → D5/GPIO14 (clock), SDI/MOSI → D7/GPIO13 (data), SDO/MISO → D6/GPIO12 (data return), CS → D1/GPIO5 (screen select), DC/RS → D2/GPIO4 (drawing control), and RESET → 3V3 (keeps the screen out of reset).
- SDI may be printed as MOSI, and SDO may be printed as MISO.
- Make sure VCC and GND are not swapped — swapped power can damage the screen.
3. Wire the touch section
Connect T_CLK → D5/GPIO14 (touch clock), T_DIN → D7/GPIO13 (touch data going in), T_DO → D6/GPIO12 (touch data coming back), T_CS → D4/GPIO2 (touch select), and T_IRQ → D0/GPIO16 (touch alert). D5, D7, and D6 each have two screen wires joined to the same NodeMCU pin; this is normal shared wiring.
- T_IRQ lets the program notice a touch without repeatedly reading the panel while your finger is away.
- Keep T_IRQ on D0 only. Do not join it to D3 because D3 is a boot pin and must remain free of screen connections.
4. Keep the upload pins free
Leave RX/GPIO3 and TX/GPIO1 with no TFT wires attached (upload connection). The touch wires T_DIN and T_DO stay on D7 and D6, not on RX and TX.
- Keeping these two pins free allows the USB or FTDI upload connection to talk to the NodeMCU.
- A screen wire on RX or TX can stop Schematik from finding the NodeMCU when you press Deploy.
5. Upload and start the game device
Plug the NodeMCU into USB. Hold its FLASH button, reconnect USB or reset it while holding FLASH, then press Deploy in Schematik. Release FLASH once uploading begins. After it restarts, the red and gold IRON STAR screen appears before the offline game launcher.
- The 10 games and calculator work without internet; TikTok and YouTube streaming need a real phone.
- If uploading still cannot find the board, check that no TFT wire is connected to RX/GPIO3 or TX/GPIO1.
Review all connections
1. Connections between "tft_ili9488_1" and "ESP32"
Deploy the firmware
#include <Arduino.h>
#include <SPI.h>
#include <TFT_eSPI.h>
enum Page { HOME1, HOME2, CALC, VIDEO, GAME };
// Forward declarations
// Forward declarations
void splash();
void button(int x, int y, int w, int h, uint16_t color, const char *text);
void top(const char *title);
uint16_t touchReadAxis(uint8_t command);
bool getTouch(int &x, int &y);
void home(Page which);
void calc();
long answer();
void videos();
void startGame(int id);
void gameDraw();
void tickGame();
constexpr uint8_t TFT_CS_PIN = 5;
constexpr uint8_t TFT_DC_PIN = 4;
// TFT RESET is wired directly to 3V3, so TFT_eSPI must not drive a reset GPIO.
constexpr uint8_t TOUCH_CS_PIN = 2; // T_CS -> D4 / GPIO2
constexpr uint8_t TOUCH_IRQ_PIN = 16; // T_IRQ -> D0 / GPIO16
constexpr uint8_t TOUCH_CLK_PIN = 14; // T_CLK and TFT SCK -> D5 / GPIO14
constexpr uint8_t TOUCH_DIN_PIN = 13; // T_DIN and TFT MOSI -> D7 / GPIO13
constexpr uint8_t TOUCH_DO_PIN = 12; // T_DO and TFT MISO -> D6 / GPIO12
TFT_eSPI tft = TFT_eSPI();
constexpr int W = 480, H = 320;
constexpr uint16_t BG = 0x0841, PANEL = 0x18E3, INK = TFT_WHITE;
constexpr uint16_t CYAN = TFT_CYAN, PINK = 0xF81F, GOLD = 0xFEA0;
Page page = HOME1;
const char *gameNames[10] = {"Dash Jump", "Tap Target", "Color Rush", "Dodge Box", "Memory Match", "Paddle Pop", "Number Pop", "Quick Reflex", "Catch Star", "Maze Tap"};
const char *gameHelp[10] = {"Tap anywhere to jump over blocks", "Tap the yellow square", "Tap only the green square", "Tap away from the red box", "Remember the flashing square", "Move paddle with your finger", "Tap numbers in order", "Tap when the screen turns green", "Catch the falling star", "Tap the next maze marker"};
int game = 0;
long score = 0;
int playerY, velocity, objectX, targetX, targetY, paddleX, needed;
uint16_t gameColor;
bool phase;
unsigned long lastFrame, phaseStarted;
String expression = "0";
void button(int x, int y, int w, int h, uint16_t color, const char *text) {
tft.fillRoundRect(x, y, w, h, 9, color);
tft.setTextColor(INK, color); tft.setTextSize(1);
// TFT_eSPI does not provide getTextBounds on ESP8266; font 1 is 6 pixels per character.
int16_t textWidth = strlen(text) * 6;
tft.setCursor(x + (w - textWidth) / 2, y + (h - 8) / 2 + 1); tft.print(text);
}
void top(const char *title) {
tft.fillRect(0, 0, W, 34, 0x0019); tft.setTextColor(INK, 0x0019); tft.setTextSize(2);
tft.setCursor(10, 9); tft.print(title); tft.setCursor(420, 9); tft.print("Home");
}
void splash() {
tft.fillScreen(TFT_BLACK);
tft.fillRoundRect(42, 76, 396, 164, 18, 0x2104);
tft.drawRoundRect(42, 76, 396, 164, 18, GOLD);
tft.setTextSize(5);
tft.setTextColor(TFT_RED, 0x2104); tft.setCursor(92, 128); tft.print("IRON");
tft.setTextColor(GOLD, 0x2104); tft.setCursor(237, 128); tft.print("STAR");
tft.setTextSize(1); tft.setTextColor(TFT_WHITE, 0x2104);
tft.setCursor(168, 198); tft.print("OFFLINE GAME PHONE");
delay(1800);
}
uint16_t touchReadAxis(uint8_t command) {
uint16_t value = 0;
digitalWrite(TOUCH_CS_PIN, LOW);
for (int bit = 7; bit >= 0; --bit) { digitalWrite(TOUCH_DIN_PIN, (command >> bit) & 1); digitalWrite(TOUCH_CLK_PIN, HIGH); digitalWrite(TOUCH_CLK_PIN, LOW); }
for (int bit = 11; bit >= 0; --bit) { digitalWrite(TOUCH_CLK_PIN, HIGH); value |= digitalRead(TOUCH_DO_PIN) << bit; digitalWrite(TOUCH_CLK_PIN, LOW); }
digitalWrite(TOUCH_CS_PIN, HIGH);
return value;
}
bool getTouch(int &x, int &y) {
uint16_t rawX = touchReadAxis(0xD0), rawY = touchReadAxis(0x90);
// An untouched XPT2046 normally reads near an end-stop on one or both axes.
if (rawX < 100 || rawX > 4000 || rawY < 100 || rawY > 4000) return false;
x = constrain(map(rawY, 220, 3850, 0, W - 1), 0, W - 1);
y = constrain(map(rawX, 250, 3800, 0, H - 1), 0, H - 1);
return true;
}
void home(Page which) {
tft.fillScreen(BG); top(which == HOME1 ? "Pocket Games 1/2" : "Pocket Games 2/2");
int start = which == HOME1 ? 0 : 5;
for (int i = 0; i < 5; ++i) button(18 + (i % 2) * 232, 48 + (i / 2) * 68, 212, 54, (i & 1) ? PINK : CYAN, gameNames[start + i]);
if (which == HOME1) { button(250, 184, 212, 54, GOLD, "Calculator"); button(18, 252, 212, 42, PANEL, "More games"); button(250, 252, 212, 42, PANEL, "Video cards"); }
else { button(18, 252, 212, 42, PANEL, "Previous games"); button(250, 252, 212, 42, GOLD, "Calculator"); }
}
void calc() {
tft.fillScreen(BG); top("Calculator"); tft.fillRoundRect(20, 46, 440, 48, 7, PANEL);
tft.setTextColor(INK, PANEL); tft.setTextSize(3); tft.setCursor(30, 58); tft.print(expression);
const char *keys[] = {"7","8","9","+","4","5","6","-","1","2","3","*","C","0","=","/"};
for (int i = 0; i < 16; i++) button(25 + (i % 4) * 110, 108 + (i / 4) * 48, 94, 38, (i % 4 == 3) ? PINK : PANEL, keys[i]);
}
long answer() { int p = -1; char op = 0; for (unsigned i = 1; i < expression.length(); i++) if (strchr("+-*/", expression[i])) { p = i; op = expression[i]; break; } if (p < 0) return expression.toInt(); long a = expression.substring(0, p).toInt(), b = expression.substring(p + 1).toInt(); if (op == '+') return a + b; if (op == '-') return a - b; if (op == '*') return a * b; return b ? a / b : 0; }
void videos() { tft.fillScreen(BG); top("Video cards"); tft.setTextColor(INK, BG); tft.setTextSize(2); tft.setCursor(25, 65); tft.print("TikTok and YouTube need a phone."); tft.setTextSize(1); tft.setCursor(25, 100); tft.print("This ESP8266 has offline cards instead of video streaming."); button(35, 145, 190, 70, PINK, "Short-video card"); button(255, 145, 190, 70, 0x780F, "YouTube card"); }
void startGame(int id) { game = id; score = 0; playerY = 245; velocity = 0; objectX = 440; targetX = random(30, 410); targetY = random(65, 220); paddleX = 210; needed = 1; gameColor = TFT_GREEN; phase = false; lastFrame = millis(); phaseStarted = millis(); tft.fillScreen(BG); top(gameNames[game]); tft.setTextColor(0xBDF7, BG); tft.setTextSize(1); tft.setCursor(10, 39); tft.print(gameHelp[game]); }
void gameDraw() {
tft.fillRect(0, 52, W, 230, BG); tft.drawFastHLine(0, 280, W, INK);
if (game == 0) { tft.fillRoundRect(60, playerY, 24, 24, 4, CYAN); tft.fillRect(objectX, 245, 20, 35, PINK); }
else if (game == 3) { tft.fillRoundRect(210, 180, 45, 45, 4, CYAN); tft.fillRoundRect(objectX, 120, 48, 48, 4, PINK); }
else if (game == 5) { tft.fillRect(paddleX, 265, 80, 10, CYAN); tft.fillCircle(objectX, 100, 9, GOLD); }
else if (game == 7) { tft.fillRoundRect(130, 105, 220, 90, 8, phase ? TFT_GREEN : PINK); tft.setTextColor(INK, phase ? TFT_GREEN : PINK); tft.setTextSize(3); tft.setCursor(185, 140); tft.print(phase ? "TAP!" : "WAIT"); }
else { tft.fillRoundRect(targetX, targetY, 55, 55, 7, gameColor); if (game == 6 || game == 9) { tft.setTextColor(TFT_BLACK, gameColor); tft.setTextSize(3); tft.setCursor(targetX + 18, targetY + 17); tft.print(needed); } }
tft.fillRect(0, 285, W, 35, 0x0019); tft.setTextColor(INK, 0x0019); tft.setTextSize(2); tft.setCursor(12, 295); tft.printf("Score: %ld", score);
}
void tickGame() { unsigned long n = millis(); if (n - lastFrame < 50) return; lastFrame = n; if (game == 0) { velocity += 2; playerY += velocity; if (playerY > 245) { playerY = 245; velocity = 0; } objectX -= 8; if (objectX < -20) { objectX = 440; score++; } if (objectX < 84 && objectX + 20 > 60 && playerY + 24 > 245) startGame(0); } else if (game == 3 || game == 5 || game == 8) { objectX -= game == 5 ? 5 : 6; if (objectX < -20) { objectX = 440; score++; } } else if (game == 7 && !phase && n - phaseStarted > (unsigned long)random(1000, 3000)) { phase = true; phaseStarted = n; } gameDraw(); }
void setup() {
pinMode(TOUCH_CS_PIN, OUTPUT); pinMode(TOUCH_IRQ_PIN, INPUT_PULLUP); pinMode(TOUCH_CLK_PIN, OUTPUT); pinMode(TOUCH_DIN_PIN, OUTPUT); pinMode(TOUCH_DO_PIN, INPUT);
digitalWrite(TOUCH_CS_PIN, HIGH); digitalWrite(TOUCH_CLK_PIN, LOW);
SPI.begin();
tft.init();
tft.setRotation(1);
splash();
randomSeed(micros());
home(HOME1);
}
void loop() {
if (page == GAME) tickGame();
int x, y; if (!getTouch(x, y)) return; delay(130);
if (x > 400 && y < 34 && page != HOME1 && page != HOME2) { page = HOME1; home(page); return; }
if (page == HOME1) { if (y >= 48 && y < 218) { int id = ((y - 48) / 68) * 2 + (x >= 240); if (id < 5) { page = GAME; startGame(id); } } else if (y > 245 && x < 240) { page = HOME2; home(page); } else if (y > 170 && y < 245 && x >= 240) { page = CALC; calc(); } else if (y > 245 && x >= 240) { page = VIDEO; videos(); } }
else if (page == HOME2) { if (y >= 48 && y < 218) { int id = 5 + ((y - 48) / 68) * 2 + (x >= 240); if (id < 10) { page = GAME; startGame(id); } } else if (y > 245 && x < 240) { page = HOME1; home(page); } else if (y > 245) { page = CALC; calc(); } }
else if (page == CALC && y > 108) { int c = (x - 25) / 110, r = (y - 108) / 48; if (c >= 0 && c < 4 && r >= 0 && r < 4) { const char *k[] = {"7","8","9","+","4","5","6","-","1","2","3","*","C","0","=","/"}; String key = k[r * 4 + c]; if (key == "C") expression = "0"; else if (key == "=") expression = String(answer()); else expression = (expression == "0" && isdigit(key[0])) ? key : expression + key; calc(); } }
else if (page == GAME) { if (game == 0 && playerY >= 245) velocity = -18; else if (game == 5) paddleX = constrain(x - 40, 0, 400); else if (game == 7 && phase) { score++; phase = false; phaseStarted = millis(); } else if (game == 3) { if (!(x > objectX && x < objectX + 48 && y > 120 && y < 168)) score++; } else if (x >= targetX && x <= targetX + 55 && y >= targetY && y <= targetY + 55) { if (game == 2 && gameColor != TFT_GREEN) {} else { score++; needed = needed % 9 + 1; targetX = random(25, 410); targetY = random(65, 220); gameColor = (game == 2) ? (random(2) ? TFT_GREEN : PINK) : GOLD; } } gameDraw(); }
}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.




