Community project

Touchscreen Demo App

ESP32
Photo of Touchscreen Demo App
Generated with AI

Pakorn Koomtritong

Published September 12, 2026

This project demonstrates a touchscreen interface running on an ESP32 microcontroller with a 320x240 ILI9341 display and XPT2046 touch controller. The guide covers wiring the display and touch panel to the ESP32, uploading the firmware, and validating touch responsiveness with a four-corner calibration check.

Builders will receive a complete parts list, wiring diagram, and step-by-step assembly instructions. The firmware uses the LovyanGFX library to render graphics and handle touch input, providing a foundation for interactive applications. After assembly, the touch check screen confirms proper connectivity before moving on to more complex interactions.

Wiring diagram

Loading diagram…

Assemble it in 3 steps

1. Set the display board down safely

Place the L0496 smart display face-up on a dry, non-metal surface. Its screen and touch layer are already built in, so do not add any jumper wires for this game.

  • Keep the clear touch surface clean so taps register reliably.
  • Do not place the powered board on loose metal objects; they can touch the pins underneath and damage the board.

2. Connect it by USB

Use a data-capable USB cable to connect the L0496 board to your computer. The USB cable supplies power and lets Schematik place the game on the board.

  • If nothing happens when you later deploy, try a different USB cable because some cables provide power only.
  • Do not force the USB plug; forcing it can damage the connector.

3. Complete the four-corner touch check

After the game starts, touch the orange TAP square in the highlighted upper-left, upper-right, lower-left, and lower-right corners in that order. The game uses those four touches to match the quiz buttons to your screen automatically.

  • Touch near the middle of each orange square and lift your finger before moving to the next one.
  • Do not skip a highlighted square or touch a different area first; the game uses this short check to decide which touch direction needs correcting.

Deploy the firmware

#include <Arduino.h>
#include <LovyanGFX.hpp>


enum AppScreen { TOUCH_CHECK, GRADE_PICKER, QUIZ }; 


// Forward declarations
void centered(const String &text, int x, int y, uint16_t foreground, uint16_t background, uint8_t size);
void button(int x, int y, int w, int h, uint16_t colour, const String &label, uint8_t size);
int randomDifferent(int forbidden, int spread);
void makeQuestion();
void drawGradePicker();
void drawQuiz();
void showResult(bool correct);
void startQuiz(int selectedGrade);
void drawTouchCheck();
void handleTouchCheck(uint16_t x, uint16_t y);
void handleTap(uint16_t x, uint16_t y);

constexpr int SCREEN_W = 320;
constexpr int SCREEN_H = 240;
constexpr int TFT_MISO_PIN = 12;
constexpr int TFT_MOSI_PIN = 13;
constexpr int TFT_SCLK_PIN = 14;
constexpr int TFT_CS_PIN = 15;
constexpr int TFT_DC_PIN = 2;
constexpr int TFT_BACKLIGHT_PIN = 21;
constexpr int TOUCH_SCLK_PIN = 25;
constexpr int TOUCH_MOSI_PIN = 32;
constexpr int TOUCH_MISO_PIN = 39;
constexpr int TOUCH_CS_PIN = 33;
constexpr int TOUCH_IRQ_PIN = 36;

class L0496Display : public lgfx::LGFX_Device {
  lgfx::Panel_ILI9341 panel;
  lgfx::Bus_SPI displayBus;
  lgfx::Light_PWM backlight;
  lgfx::Touch_XPT2046 touch;
public:
  L0496Display() {
    auto busConfig = displayBus.config();
    busConfig.spi_host = HSPI_HOST;
    busConfig.spi_mode = 0;
    busConfig.freq_write = 40000000;
    busConfig.freq_read = 16000000;
    busConfig.spi_3wire = false;
    busConfig.use_lock = true;
    busConfig.dma_channel = 1;
    busConfig.pin_sclk = TFT_SCLK_PIN;
    busConfig.pin_mosi = TFT_MOSI_PIN;
    busConfig.pin_miso = TFT_MISO_PIN;
    busConfig.pin_dc = TFT_DC_PIN;
    displayBus.config(busConfig);
    panel.setBus(&displayBus);

    auto panelConfig = panel.config();
    panelConfig.pin_cs = TFT_CS_PIN;
    panelConfig.pin_rst = -1;
    panelConfig.panel_width = 240;
    panelConfig.panel_height = 320;
    panelConfig.offset_x = 0;
    panelConfig.offset_y = 0;
    panelConfig.readable = true;
    panelConfig.invert = true;
    panelConfig.rgb_order = false;
    panel.config(panelConfig);

    auto lightConfig = backlight.config();
    lightConfig.pin_bl = TFT_BACKLIGHT_PIN;
    lightConfig.invert = false;
    lightConfig.freq = 44100;
    lightConfig.pwm_channel = 7;
    backlight.config(lightConfig);
    panel.setLight(&backlight);

    auto touchConfig = touch.config();
    // Landscape calibration: reverse left/right only; keep top/bottom normal.
    touchConfig.x_min = 3800;
    touchConfig.x_max = 300;
    touchConfig.y_min = 300;
    touchConfig.y_max = 3800;
    // Poll the touch controller directly. Some L0496 revisions do not present
    // the XPT2046 interrupt line reliably, even though touch SPI is available.
    touchConfig.pin_int = -1;
    touchConfig.bus_shared = false;
    touchConfig.offset_rotation = 0;
    touchConfig.spi_host = VSPI_HOST;
    touchConfig.freq = 1000000;
    touchConfig.pin_sclk = TOUCH_SCLK_PIN;
    touchConfig.pin_mosi = TOUCH_MOSI_PIN;
    touchConfig.pin_miso = TOUCH_MISO_PIN;
    touchConfig.pin_cs = TOUCH_CS_PIN;
    touch.config(touchConfig);
    panel.setTouch(&touch);
    setPanel(&panel);
  }
};

L0496Display display;


AppScreen appScreen = GRADE_PICKER;
int grade = 1;
int score = 0;
int questionNumber = 0;
int answer = 0;
int choices[4];
String questionText;
bool wasTouched = false;
bool showingResult = false;
unsigned long resultStartedAt = 0;

// The four-corner check learns whether this particular panel is mirrored.
uint16_t cornerX[4] = {0, 0, 0, 0};
uint16_t cornerY[4] = {0, 0, 0, 0};
uint8_t cornerStep = 0;
bool touchFlipX = false;
bool touchFlipY = false;

constexpr uint16_t NAVY = 0x10A4;
constexpr uint16_t BLUE = 0x249F;
constexpr uint16_t PURPLE = 0x79B8;
constexpr uint16_t GREEN = 0x0640;
constexpr uint16_t ORANGE = 0xFD20;
constexpr uint16_t RED = 0xD904;
constexpr uint16_t WHITE = 0xFFFF;
constexpr uint16_t PALE = 0xD6BF;

void centered(const String &text, int x, int y, uint16_t foreground, uint16_t background, uint8_t size = 1) {
  display.setTextSize(size);
  display.setTextColor(foreground, background);
  display.setTextDatum(middle_center);
  display.drawString(text, x, y);
}

void button(int x, int y, int w, int h, uint16_t colour, const String &label, uint8_t size = 1) {
  display.fillRoundRect(x, y, w, h, 9, colour);
  centered(label, x + w / 2, y + h / 2, WHITE, colour, size);
}

int randomDifferent(int forbidden, int spread) {
  int value;
  do {
    value = answer + random(-spread, spread + 1);
    if (value < 0) value = random(0, spread * 2 + 1);
  } while (value == forbidden || value == answer);
  return value;
}

void makeQuestion() {
  int a = 0;
  int b = 0;
  int kind = 0;
  int limit = 10;

  if (grade == 1) {
    a = random(0, 11); b = random(0, 11 - a); answer = a + b;
    questionText = String(a) + " + " + String(b) + " = ?";
  } else if (grade == 2) {
    limit = 20; kind = random(0, 2);
    a = random(0, limit + 1); b = random(0, limit + 1);
    if (kind == 0 && a + b <= limit) { answer = a + b; questionText = String(a) + " + " + String(b) + " = ?"; }
    else { if (b > a) { int t = a; a = b; b = t; } answer = a - b; questionText = String(a) + " - " + String(b) + " = ?"; }
  } else if (grade == 3) {
    limit = 100; kind = random(0, 2); a = random(0, limit + 1); b = random(0, limit + 1);
    if (kind == 0 && a + b <= limit) { answer = a + b; questionText = String(a) + " + " + String(b) + " = ?"; }
    else { if (b > a) { int t = a; a = b; b = t; } answer = a - b; questionText = String(a) + " - " + String(b) + " = ?"; }
  } else {
    kind = random(0, grade == 6 ? 3 : 2);
    if (kind == 0) {
      a = random(2, grade == 4 ? 11 : 13); b = random(2, grade == 4 ? 11 : 21);
      answer = a * b; questionText = String(a) + " x " + String(b) + " = ?";
    } else if (kind == 1) {
      b = random(2, grade == 4 ? 11 : 13); answer = random(2, grade == 4 ? 11 : 21); a = b * answer;
      questionText = String(a) + " / " + String(b) + " = ?";
    } else {
      a = random(100, 501); b = random(10, 200); answer = a + b;
      questionText = String(a) + " + " + String(b) + " = ?";
    }
  }

  int correctSlot = random(0, 4);
  for (int i = 0; i < 4; ++i) choices[i] = -1;
  choices[correctSlot] = answer;
  int spread = max(5, answer / 3 + 2);
  for (int i = 0; i < 4; ++i) {
    if (i == correctSlot) continue;
    int candidate;
    bool duplicate;
    do {
      candidate = randomDifferent(-9999, spread);
      duplicate = false;
      for (int j = 0; j < 4; ++j) if (choices[j] == candidate) duplicate = true;
    } while (duplicate);
    choices[i] = candidate;
  }
  ++questionNumber;
}

void drawTouchCheck() {
  static const char *cornerNames[] = {"upper-left", "upper-right", "lower-left", "lower-right"};
  display.fillScreen(NAVY);
  centered("Touch check", 160, 26, WHITE, NAVY, 2);
  centered("Touch the highlighted corner", 160, 52, PALE, NAVY);
  centered(String("Step ") + String(cornerStep + 1) + " of 4", 160, 76, WHITE, NAVY);
  const int markerSize = 48;
  const int margin = 12;
  int markerX = (cornerStep == 1 || cornerStep == 3) ? SCREEN_W - margin - markerSize : margin;
  int markerY = (cornerStep >= 2) ? SCREEN_H - margin - markerSize : 92;
  display.fillRoundRect(markerX, markerY, markerSize, markerSize, 10, ORANGE);
  centered("TAP", markerX + markerSize / 2, markerY + markerSize / 2, WHITE, ORANGE);
  centered(cornerNames[cornerStep], 160, 218, PALE, NAVY);
}

void drawGradePicker() {
  display.fillScreen(NAVY);
  centered("Maths Quest", 160, 27, WHITE, NAVY, 2);
  centered("Choose your school grade", 160, 52, PALE, NAVY);
  for (int i = 0; i < 6; ++i) {
    int column = i % 2;
    int row = i / 2;
    button(24 + column * 148, 76 + row * 48, 124, 38, column == 0 ? BLUE : PURPLE, String("Grade ") + String(i + 1));
  }
  centered("Answer questions to earn points!", 160, 228, PALE, NAVY);
}

void drawQuiz() {
  display.fillScreen(NAVY);
  display.fillRect(0, 0, SCREEN_W, 34, BLUE);
  centered(String("Grade ") + String(grade), 52, 17, WHITE, BLUE);
  centered(String("Score: ") + String(score), 160, 17, WHITE, BLUE);
  button(258, 5, 54, 24, RED, "Home");
  centered(questionText, 160, 73, WHITE, NAVY, 2);
  for (int i = 0; i < 4; ++i) {
    int x = 20 + (i % 2) * 145;
    int y = 112 + (i / 2) * 58;
    button(x, y, 135, 46, i % 2 == 0 ? GREEN : ORANGE, String(choices[i]), 2);
  }
  centered("Tap the correct answer", 160, 222, PALE, NAVY);
}

void showResult(bool correct) {
  display.fillRoundRect(48, 83, 224, 60, 10, correct ? GREEN : RED);
  centered(correct ? "Great job! +1 point" : String("Not quite. It is ") + String(answer), 160, 113, WHITE, correct ? GREEN : RED, 1);
  showingResult = true;
  resultStartedAt = millis();
}

void startQuiz(int selectedGrade) {
  grade = selectedGrade;
  score = 0;
  questionNumber = 0;
  appScreen = QUIZ;
  makeQuestion();
  drawQuiz();
}

void handleTouchCheck(uint16_t x, uint16_t y) {
  cornerX[cornerStep] = x;
  cornerY[cornerStep] = y;
  ++cornerStep;
  if (cornerStep < 4) {
    drawTouchCheck();
    return;
  }
  // Compare physical left/right and top/bottom taps. If their reported order is
  // backwards, reverse only that coordinate before the quiz uses it.
  touchFlipX = cornerX[0] > cornerX[1];
  touchFlipY = cornerY[0] > cornerY[2];
  Serial.printf("Touch check complete: flipX=%d flipY=%d\n", touchFlipX, touchFlipY);
  appScreen = GRADE_PICKER;
  drawGradePicker();
}

void handleTap(uint16_t x, uint16_t y) {
  if (appScreen == TOUCH_CHECK) {
    handleTouchCheck(x, y);
    return;
  }
  if (touchFlipX) x = SCREEN_W - 1 - x;
  if (touchFlipY) y = SCREEN_H - 1 - y;
  if (appScreen == GRADE_PICKER) {
    int row = -1;
    if (y >= 76 && y < 114) row = 0;
    else if (y >= 124 && y < 162) row = 1;
    else if (y >= 172 && y < 210) row = 2;
    if (row >= 0) {
      if (x >= 24 && x < 148) startQuiz(row * 2 + 1);
      else if (x >= 172 && x < 296) startQuiz(row * 2 + 2);
    }
    return;
  }
  if (showingResult) return;
  if (y < 34 && x >= 258) { appScreen = GRADE_PICKER; drawGradePicker(); return; }
  // Only score a tap that falls inside one of the four visible answer buttons.
  // This leaves the small spaces between buttons inactive instead of selecting a neighbour.
  for (int i = 0; i < 4; ++i) {
    const int buttonX = 20 + (i % 2) * 145;
    const int buttonY = 112 + (i / 2) * 58;
    const bool insideButton = x >= buttonX && x < buttonX + 135 &&
                              y >= buttonY && y < buttonY + 46;
    if (insideButton) {
      const bool correct = choices[i] == answer;
      if (correct) ++score;
      showResult(correct);
      return;
    }
  }
}

void setup() {
  Serial.begin(115200);
  delay(100);
  display.init();
  display.setRotation(1);
  display.setBrightness(255);
  randomSeed(esp_random());
  Serial.println("Maths Quest started");
  appScreen = TOUCH_CHECK;
  drawTouchCheck();
}

void loop() {
  uint16_t x, y;
  bool touched = display.getTouch(&x, &y);
  if (touched && !wasTouched) {
    handleTap(x, y);
  }
  wasTouched = touched;
  if (showingResult && millis() - resultStartedAt >= 900) {
    showingResult = false;
    makeQuestion();
    drawQuiz();
  }
  delay(20);
}

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