Community project

Steering Wheel Controller

finnemary

Published July 14, 2026

ESP3211 components10 assembly steps
Remix this project

Build a racing sim controller with steering wheel, pedals, paddle shifters, and a button box—all controlled by an ESP32-S3 and displayed on a 3.4-inch touchscreen. This guide walks through wiring the potentiometers for analog inputs, connecting the six-button control panel, and setting up the ignition key switch and mode selector. The controller emulates a USB gamepad, making it compatible with racing simulators on PC.

This project delivers a complete parts list, wiring diagram, and ready-to-flash firmware that handles all analog and digital inputs while rendering a live dashboard overlay on the TFT display. Assembly takes a few hours and requires basic soldering; no prior experience with game controllers or ESP32 development is needed.

Wiring diagram

Interactive · read-only
Wiring diagram for Steering Wheel Controller

Pan and zoom to explore the wiring. Remix the project to edit it in your own workspace.

Parts list

Bill of materials
ComponentQtyNotes
10kΩ Potentiometer10kΩ1A 3-terminal passive resistive voltage divider with a total resistance of 10kΩ. One end connects to 5V, the other to GND, and the wiper outputs a variable voltage between 0V and 5V.
10kΩ Potentiometer10kΩ1A 3-terminal passive resistive voltage divider with a total resistance of 10kΩ. One end connects to 5V, the other to GND, and the wiper outputs a variable voltage between 0V and 5V.
10kΩ Potentiometer10kΩ1A 3-terminal passive resistive voltage divider with a total resistance of 10kΩ. One end connects to 5V, the other to GND, and the wiper outputs a variable voltage between 0V and 5V.
10kΩ Potentiometer10kΩ1A 3-terminal passive resistive voltage divider with a total resistance of 10kΩ. One end connects to 5V, the other to GND, and the wiper outputs a variable voltage between 0V and 5V.
Push ButtonShift Up (Right Paddle)1Momentary push button switch
Push ButtonShift Down (Left Paddle)1Momentary push button switch
Push ButtonManual/Auto Toggle1Momentary push button switch
ILI9341 TFT Touchscreen1240x320 SPI TFT display using the ILI9341 LCD controller with an XPT2046 resistive touch controller sharing the SPI bus
10kΩ Potentiometer10kΩ1A 3-terminal passive resistive voltage divider with a total resistance of 10kΩ. One end connects to 5V, the other to GND, and the wiper outputs a variable voltage between 0V and 5V.
2-Position Key Switch1A panel-mount key switch with two positions: OFF and ON/ACC. Outputs a digital HIGH when turned to ON, LOW when off. Used to trigger engine start/stop in the sim.
Button Box (6 Buttons)1Six panel-mount momentary buttons for sim functions: Lights, Wipers, Traction Control, ABS, Pit Limiter, DRS. Each button connects between signal pin and GND, using internal pull-ups.

Assembly

10 steps
  1. Mount the ESP32-S3 DevKitC-1

    Mount the ESP32-S3-DevKitC-1 board centrally inside the cabinet on a non-conductive base. Secure it with standoffs or double-sided tape. Leave the USB-C port accessible — this is your PC connection for HID and power.

    • Tip: Route a USB-C cable from the board port to the front or rear of the cabinet for easy PC connection.
  2. Wire the steering wheel potentiometer

    Mount a 10kΩ linear pot on the steering column shaft. Connect End1 to the 3.3V pin, End2 to GND, and Wiper to GPIO1.

    • Use 3.3V only — NEVER 5V — for all potentiometers. The ESP32-S3 ADC is 3.3V max.
  3. Wire the pedal potentiometers

    Mount three 10kΩ pots on the gas, brake, and clutch pedal mechanisms. For each: End1 → 3.3V, End2 → GND. Gas Wiper → GPIO2, Brake Wiper → GPIO3, Clutch Wiper → GPIO4.

    • Tip: A return spring or rubber stop on each pedal makes the sim feel much more realistic.
  4. Wire the handbrake lever potentiometer

    Mount a 10kΩ pot at the pivot of your handbrake lever so pulling the lever rotates the pot shaft. Connect End1 → 3.3V, End2 → GND, Wiper → GPIO5.

    • Tip: A short lever (10–15 cm) with the pot at the base pivot gives the best feel.
  5. Wire the paddle shifters

    Mount two momentary switches behind the steering wheel — right paddle to GPIO14, left paddle to GPIO21. Both share GND. The firmware uses internal pull-ups, so no external resistors needed.

    • Tip: Micro-switches (like those in a mouse) give a satisfying click feel for paddle shifters.
  6. Wire the Manual/Auto mode button

    Mount a momentary button on the dashboard or steering column. Connect SIGNAL to GPIO38 and GND to GND.

    • Tip: A flush-mount panel button works well here — label it M/A.
  7. Wire the ignition key switch

    Mount a 2-position panel key switch on the dashboard. Connect SIGNAL to GPIO6 and GND to GND. The firmware uses an internal pull-up — turning the key LOW triggers engine ON/OFF toggle.

    • Tip: Key switches are available on AliExpress/Amazon for ~$2 in panel-mount format.
    • Tip: A short barrel-key switch (12mm panel mount) looks the most realistic.
    • The key switch toggles engine state on each turn — make sure it's in OFF position before powering up.
  8. Wire the button box

    Mount 6 momentary buttons on a panel (the button box). Wire them as follows — all share a common GND rail: • BTN1 (Lights) → GPIO7 • BTN2 (Wipers) → GPIO15 • BTN3 (Traction Control) → GPIO16 • BTN4 (ABS) → GPIO17 • BTN5 (Pit Limiter) → GPIO18 • BTN6 (DRS) → GPIO47

    • Tip: Label each button clearly with a label maker or printed panel insert.
    • Tip: 16mm or 19mm illuminated panel buttons look great on a button box — the LED ring just needs 3.3V and a resistor.
  9. Wire the TFT touchscreen display

    Connect the ILI9341 2.8" TFT to the SPI bus: • VCC → 3.3V • GND → GND • MOSI → GPIO11 • MISO → GPIO13 • SCK → GPIO12 • TFT_CS → GPIO10 • TFT_DC → GPIO39 • TFT_RST → GPIO40 • TOUCH_CS → GPIO41 • TOUCH_IRQ → GPIO42 Mount the screen on the cabinet dashboard where the driver can see it as an instrument cluster.

    • Tip: Hot-glue or a 3D-printed bezel looks great for mounting the TFT in the dashboard.
    • Tip: The TFT shows Speed (MPH) and Gear only — touch MENU CAR/BG to change the theme.
  10. Connect to PC and open the overlay

    Plug the USB-C cable from the ESP32-S3 into your PC. The board appears as a USB HID gamepad immediately. Open data/pc_overlay.html in Chrome or Edge, click anywhere on the page, and select the ESP32 COM port from the browser's serial picker (115200 baud). The overlay shows mode badge, gas/brake/handbrake bars, steering indicator, engine status, and button box lights.

    • Tip: If the serial picker doesn't appear, make sure you're opening the file via localhost or a local web server — Web Serial only works on HTTPS or localhost in Chrome/Edge.
    • Tip: Use joy.cpl on Windows to verify all axes and buttons before launching a game.

Firmware

ESP32
main.cppDeploy to device
#include <Arduino.h>
#include <SPI.h>
#include <Adafruit_GFX.h>
#include <Adafruit_ILI9341.h>
#include <XPT2046_Touchscreen.h>
#include "USB.h"
#include "USBHIDGamepad.h"

// ── Pin definitions ──────────────────────────────────────────
#define WHEEL_PIN      1
#define GAS_PIN        2
#define BRAKE_PIN      3
#define CLUTCH_PIN     4
#define PADDLE_UP_PIN  14
#define PADDLE_DN_PIN  21
#define MODE_BTN_PIN   38
#define HANDBRAKE_PIN  5
#define IGNITION_PIN   6
#define BTN1_PIN       7
#define BTN2_PIN       15
#define BTN3_PIN       16
#define BTN4_PIN       17
#define BTN5_PIN       18
#define BTN6_PIN       47
#define TFT_CS         10
#define TFT_DC         39
#define TFT_RST        40
#define TOUCH_CS       41
#define TOUCH_IRQ      42

// ── Display & Touch ──────────────────────────────────────────

// Only two states now: dashboard or overlay open
bool menuOpen = false;

Adafruit_ILI9341 tft = Adafruit_ILI9341(TFT_CS, TFT_DC, TFT_RST);
XPT2046_Touchscreen touch(TOUCH_CS, TOUCH_IRQ);

// ── USB HID Gamepad ──────────────────────────────────────────
USBHIDGamepad Gamepad;

// ── Colours ──────────────────────────────────────────────────
#define C_BG       0x0841   // dark charcoal
#define C_RED      0xF800
#define C_GREEN    0x07E0
#define C_BLUE     0x001F
#define C_YELLOW   0xFFE0
#define C_WHITE    0xFFFF
#define C_GREY     0x8410
#define C_ORANGE   0xFD20
#define C_DARKGREY 0x4208

// ── Car & background presets ────────────────────────────────
const char* CAR_NAMES[]  = {"F1 Car", "GT500",  "Rally",  "Muscle"};
const char* BG_NAMES[]   = {"Night",  "Sunset", "Desert", "Track" };
const uint16_t CAR_COLOURS[] = {C_RED, C_BLUE, C_YELLOW, C_ORANGE};
const uint16_t BG_COLOURS[]  = {0x0841, 0x8C00, 0xC600, 0x0460};
#define NUM_CARS 4
#define NUM_BGS  4

// ── State ────────────────────────────────────────────────────
bool     autoMode       = false;
int      gear           = 1;
uint32_t lastShiftMs    = 0;
const uint32_t DEBOUNCE_MS    = 50;
const uint32_t AUTO_SHIFT_MS  = 800;
#define AUTO_UP_THRESH  200   // out of 255 (~78%)
#define AUTO_DN_THRESH   50   // out of 255 (~20%)

int      selectedCar    = 0;
int      selectedBg     = 0;

// Previous values for selective TFT redraw
int      prevGear       = -1;
int      prevSpeedDisp  = -1;

// ── Touch calibration (adjust if needed) ────────────────────
#define TOUCH_X_MIN  350
#define TOUCH_X_MAX  3700
#define TOUCH_Y_MIN  350
#define TOUCH_Y_MAX  3800

// ── Forward declarations ─────────────────────────────────────
void drawDashboard(bool full);
void drawMenuOverlay();
void closeMenu();
void handleTouch();
int  mapTouch(int raw, int rMin, int rMax, int oMin, int oMax);

// ─────────────────────────────────────────────────────────────
void setup() {
  // Buttons & switches
  pinMode(PADDLE_UP_PIN, INPUT_PULLUP);
  pinMode(PADDLE_DN_PIN, INPUT_PULLUP);
  pinMode(MODE_BTN_PIN,  INPUT_PULLUP);
  pinMode(IGNITION_PIN,  INPUT_PULLUP);
  pinMode(BTN1_PIN,      INPUT_PULLUP);
  pinMode(BTN2_PIN,      INPUT_PULLUP);
  pinMode(BTN3_PIN,      INPUT_PULLUP);
  pinMode(BTN4_PIN,      INPUT_PULLUP);
  pinMode(BTN5_PIN,      INPUT_PULLUP);
  pinMode(BTN6_PIN,      INPUT_PULLUP);

  // Serial (for PC companion overlay)
  Serial.begin(115200);

  // Display
  SPI.begin(12, 13, 11); // SCK, MISO, MOSI
  tft.begin();
  tft.setRotation(1);    // landscape
  touch.begin();
  touch.setRotation(1);

  menuOpen = false;
  tft.fillScreen(BG_COLOURS[selectedBg]);
  drawDashboard(true);

  // USB HID
  Gamepad.begin();
  USB.begin();
}

// ─────────────────────────────────────────────────────────────
void loop() {
  uint32_t now = millis();

  // ── Read ADC axes ────────────────────────────────────────
  int rawWheel     = analogRead(WHEEL_PIN);
  int rawGas       = analogRead(GAS_PIN);
  int rawBrake     = analogRead(BRAKE_PIN);
  int rawClutch    = analogRead(CLUTCH_PIN);
  int rawHandbrake = analogRead(HANDBRAKE_PIN);

  // Map to 0–255 for HID
  int axisWheel     = map(rawWheel,     0, 4095, -127, 127);
  int axisGas       = map(rawGas,       0, 4095,    0, 255);
  int axisBrake     = map(rawBrake,     0, 4095,    0, 255);
  int axisClutch    = map(rawClutch,    0, 4095,    0, 255);
  int axisHandbrake = map(rawHandbrake, 0, 4095,    0, 255);

  // ── Ignition key ─────────────────────────────────────────
  static bool lastIgnition = HIGH;
  static bool engineOn     = false;
  bool ignState = digitalRead(IGNITION_PIN);
  if (lastIgnition == HIGH && ignState == LOW) {
    engineOn = !engineOn;  // toggle on key turn
    if (!engineOn) { gear = 1; }  // reset gear on engine off
  }
  lastIgnition = ignState;

  // ── Button box reads ─────────────────────────────────────
  bool bb1 = !digitalRead(BTN1_PIN); // active LOW
  bool bb2 = !digitalRead(BTN2_PIN);
  bool bb3 = !digitalRead(BTN3_PIN);
  bool bb4 = !digitalRead(BTN4_PIN);
  bool bb5 = !digitalRead(BTN5_PIN);
  bool bb6 = !digitalRead(BTN6_PIN);

  // ── Mode toggle ──────────────────────────────────────────
  static bool lastModeBtn = HIGH;
  bool modeBtn = digitalRead(MODE_BTN_PIN);
  if (lastModeBtn == HIGH && modeBtn == LOW && (now - lastShiftMs) > DEBOUNCE_MS) {
    autoMode = !autoMode;
    gear = 1;
    lastShiftMs = now;
    if (!menuOpen) drawDashboard(true);
  }
  lastModeBtn = modeBtn;

  // ── Shifting ─────────────────────────────────────────────
  if (!autoMode) {
    static bool lastUp = HIGH, lastDn = HIGH;
    bool upBtn = digitalRead(PADDLE_UP_PIN);
    bool dnBtn = digitalRead(PADDLE_DN_PIN);
    if (lastUp == HIGH && upBtn == LOW && (now - lastShiftMs) > DEBOUNCE_MS) {
      if (gear < 6) { gear++; lastShiftMs = now; }
    }
    if (lastDn == HIGH && dnBtn == LOW && (now - lastShiftMs) > DEBOUNCE_MS) {
      if (gear > 1) { gear--; lastShiftMs = now; }
    }
    lastUp = upBtn;
    lastDn = dnBtn;
  } else {
    if ((now - lastShiftMs) > AUTO_SHIFT_MS) {
      if (axisGas > AUTO_UP_THRESH && gear < 6) { gear++; lastShiftMs = now; }
      else if (axisGas < AUTO_DN_THRESH && gear > 1) { gear--; lastShiftMs = now; }
    }
  }

  // ── Simulated speed (MPH) from gas + gear ───────────────
  // Pure display value — real speed comes from the game on the PC
  int simSpeed = (axisGas * gear * 15) / 255;
  if (simSpeed > 199) simSpeed = 199;

  // ── HID report ───────────────────────────────────────────
  Gamepad.leftStick(axisWheel, 0);
  Gamepad.rightStick(axisHandbrake, axisClutch); // handbrake = Ry, clutch = Rx
  uint32_t buttons = 0;
  buttons |= (1u << (gear - 1));           // buttons 1-6: gear
  if (autoMode)   buttons |= (1u << 6);    // button 7: auto mode
  if (engineOn)   buttons |= (1u << 7);    // button 8: ignition/engine
  if (bb1)        buttons |= (1u << 8);    // button 9:  Lights
  if (bb2)        buttons |= (1u << 9);    // button 10: Wipers
  if (bb3)        buttons |= (1u << 10);   // button 11: Traction Control
  if (bb4)        buttons |= (1u << 11);   // button 12: ABS
  if (bb5)        buttons |= (1u << 12);   // button 13: Pit Limiter
  if (bb6)        buttons |= (1u << 13);   // button 14: DRS
  Gamepad.buttons(buttons);
  Gamepad.send();

  // ── Touch / menu ─────────────────────────────────────────
  handleTouch();

  // ── Serial JSON → PC overlay ─────────────────────────────
  // Send at ~20 Hz so the PC companion app can display
  // mode, gas, brake, steering without adding display load to TFT
  static uint32_t lastSerialMs = 0;
  if (now - lastSerialMs >= 50) {
    lastSerialMs = now;
    int gasDisp    = axisGas    * 100 / 255;
    int brakeDisp  = axisBrake  * 100 / 255;
    int steerDisp  = map(rawWheel, 0, 4095, -100, 100);
    Serial.print('{');
    Serial.print("\"mode\":\"");    Serial.print(autoMode ? "AUTO" : "MANUAL"); Serial.print('"');
    Serial.print(",\"gas\":");      Serial.print(gasDisp);
    Serial.print(",\"brake\":");    Serial.print(brakeDisp);
    Serial.print(",\"steer\":");    Serial.print(steerDisp);
    Serial.print(",\"gear\":");     Serial.print(gear);
    Serial.print(",\"speed\":");       Serial.print((axisGas * gear * 15) / 255);
    Serial.print(",\"handbrake\":");   Serial.print(axisHandbrake * 100 / 255);
    Serial.print(",\"engine\":");      Serial.print(engineOn ? 1 : 0);
    Serial.print(",\"lights\":");      Serial.print(bb1 ? 1 : 0);
    Serial.print(",\"wipers\":");      Serial.print(bb2 ? 1 : 0);
    Serial.print(",\"tc\":");          Serial.print(bb3 ? 1 : 0);
    Serial.print(",\"abs\":");         Serial.print(bb4 ? 1 : 0);
    Serial.print(",\"pitlimit\":");    Serial.print(bb5 ? 1 : 0);
    Serial.print(",\"drs\":");         Serial.print(bb6 ? 1 : 0);
    Serial.println('}');
  }

  // ── TFT instrument cluster update (speed + gear only) ─────
  if (!menuOpen) {
    int speedDisp = (axisGas * gear * 15) / 255;
    if (speedDisp > 199) speedDisp = 199;
    if (gear != prevGear || speedDisp != prevSpeedDisp) {
      drawDashboard(false);
      prevGear      = gear;
      prevSpeedDisp = speedDisp;
    }
  }

  delay(10);
}

// ─────────────────────────────────────────────────────────────
int mapTouch(int raw, int rMin, int rMax, int oMin, int oMax) {
  return constrain(map(raw, rMin, rMax, oMin, oMax), oMin, oMax);
}

// ── Draw the TFT instrument cluster: Speed + Gear only ───────
void drawDashboard(bool full) {
  int rawGas   = analogRead(GAS_PIN);
  int axisGas  = map(rawGas, 0, 4095, 0, 255);
  int simSpeed = (axisGas * gear * 15) / 255;
  if (simSpeed > 199) simSpeed = 199;

  uint16_t bg  = BG_COLOURS[selectedBg];
  uint16_t acc = CAR_COLOURS[selectedCar];

  if (full) {
    tft.fillScreen(bg);

    // ── Title bar ─────────────────────────────────────────
    tft.fillRect(0, 0, 320, 26, C_DARKGREY);
    tft.setTextColor(C_WHITE); tft.setTextSize(1);
    tft.setCursor(6, 9);
    tft.print("INSTRUMENT CLUSTER");
    tft.setTextColor(acc);
    tft.setCursor(210, 9);
    tft.print(CAR_NAMES[selectedCar]);

    // ── Divider line ──────────────────────────────────────
    tft.drawFastHLine(0, 26, 320, C_GREY);
    tft.drawFastVLine(160, 26, 194, C_GREY); // centre divider

    // ── Labels ────────────────────────────────────────────
    tft.setTextColor(C_GREY); tft.setTextSize(1);
    tft.setCursor(10,  34); tft.print("SPEED (MPH)");
    tft.setCursor(172, 34); tft.print("GEAR");

    // ── MENU button (bottom-right, touchable) ─────────────
    tft.fillRoundRect(222, 208, 88, 24, 6, C_DARKGREY);
    tft.setTextColor(C_WHITE); tft.setTextSize(1);
    tft.setCursor(234, 218); tft.print("MENU CAR/BG");
  } else {
    // Erase number areas only
    tft.fillRect(6,  48, 148, 90, bg);
    tft.fillRect(166, 48, 148, 90, bg);
  }

  // ── Speed — large centred in left half ────────────────────
  tft.setTextColor(C_WHITE); tft.setTextSize(7);
  // 3-digit field: each char ~42px wide at size 7
  int sx = (simSpeed < 10) ? 46 : (simSpeed < 100) ? 25 : 6;
  tft.setCursor(sx, 52);
  tft.print(simSpeed);

  // MPH unit label
  tft.setTextColor(C_GREY); tft.setTextSize(1);
  tft.setCursor(10, 142); tft.print("MPH");

  // ── Gear — large centred in right half ────────────────────
  tft.setTextColor(acc); tft.setTextSize(7);
  tft.setCursor(204, 52);   // single digit, centred in right half
  tft.print(gear);
}

// ── Menu overlay (drawn ON TOP of the dashboard) ─────────────
// Layout (centred panel 300x220 at x=10, y=10):
//   Title row
//   CAR section:  4 car buttons in 2 columns
//   BG  section:  4 bg  buttons in 2 columns
//   CLOSE button at bottom
#define OVL_X   10
#define OVL_Y   8
#define OVL_W   300
#define OVL_H   226
#define OVL_BTN_W  130
#define OVL_BTN_H  26

void drawMenuOverlay() {
  // Dim background with a dark semi-frame
  tft.fillRoundRect(OVL_X, OVL_Y, OVL_W, OVL_H, 10, 0x1082); // very dark grey
  tft.drawRoundRect(OVL_X, OVL_Y, OVL_W, OVL_H, 10, C_WHITE);

  // Title
  tft.setTextColor(C_WHITE); tft.setTextSize(1);
  tft.setCursor(OVL_X + 8, OVL_Y + 6);
  tft.print("SETTINGS");
  // Divider
  tft.drawFastHLine(OVL_X + 1, OVL_Y + 18, OVL_W - 2, C_GREY);

  // ── CAR section ────────────────────────────────────────────
  tft.setTextColor(C_GREY); tft.setTextSize(1);
  tft.setCursor(OVL_X + 8, OVL_Y + 22);
  tft.print("CAR");

  // 4 cars in 2 columns, 2 rows  (each button 130x26)
  for (int i = 0; i < NUM_CARS; i++) {
    int col = i % 2;                    // 0 or 1
    int row = i / 2;                    // 0 or 1
    int bx = OVL_X + 8 + col * (OVL_BTN_W + 6);
    int by = OVL_Y + 32 + row * (OVL_BTN_H + 6);
    bool sel = (i == selectedCar);
    tft.fillRoundRect(bx, by, OVL_BTN_W, OVL_BTN_H, 5,
                      sel ? CAR_COLOURS[i] : C_DARKGREY);
    if (sel) tft.drawRoundRect(bx, by, OVL_BTN_W, OVL_BTN_H, 5, C_WHITE);
    tft.setTextColor(C_WHITE); tft.setTextSize(1);
    tft.setCursor(bx + 6, by + 9);
    tft.print(CAR_NAMES[i]);
  }

  // ── BG section ─────────────────────────────────────────────
  int bgSectionY = OVL_Y + 32 + 2 * (OVL_BTN_H + 6) + 8;
  tft.drawFastHLine(OVL_X + 1, bgSectionY - 4, OVL_W - 2, C_DARKGREY);
  tft.setTextColor(C_GREY); tft.setTextSize(1);
  tft.setCursor(OVL_X + 8, bgSectionY);
  tft.print("BACKGROUND");

  for (int i = 0; i < NUM_BGS; i++) {
    int col = i % 2;
    int row = i / 2;
    int bx = OVL_X + 8 + col * (OVL_BTN_W + 6);
    int by = bgSectionY + 12 + row * (OVL_BTN_H + 6);
    bool sel = (i == selectedBg);
    tft.fillRoundRect(bx, by, OVL_BTN_W, OVL_BTN_H, 5, BG_COLOURS[i]);
    if (sel) tft.drawRoundRect(bx, by, OVL_BTN_W, OVL_BTN_H, 5, C_WHITE);
    tft.setTextColor(C_WHITE); tft.setTextSize(1);
    tft.setCursor(bx + 6, by + 9);
    tft.print(BG_NAMES[i]);
  }

  // ── CLOSE button ───────────────────────────────────────────
  int closeY = OVL_Y + OVL_H - 30;
  tft.fillRoundRect(OVL_X + 80, closeY, 140, 24, 6, C_GREEN);
  tft.setTextColor(0x0000); tft.setTextSize(1);
  tft.setCursor(OVL_X + 112, closeY + 8);
  tft.print("CLOSE");
}

void closeMenu() {
  menuOpen = false;
  drawDashboard(true); // full redraw restores dashboard
}

// ── Touch handler ────────────────────────────────────────────
void handleTouch() {
  if (!touch.touched()) return;
  TS_Point p = touch.getPoint();

  // Map raw touch to screen coords (landscape)
  int tx = mapTouch(p.x, TOUCH_X_MIN, TOUCH_X_MAX, 0, 320);
  int ty = mapTouch(p.y, TOUCH_Y_MIN, TOUCH_Y_MAX, 0, 240);

  delay(30); // debounce touch
  while (touch.touched()) {};

  if (!menuOpen) {
    // ── Dashboard: only the MENU button is tappable ──────────
    if (tx >= 222 && tx <= 310 && ty >= 196 && ty <= 222) {
      menuOpen = true;
      drawMenuOverlay();
    }
  } else {
    // ── Overlay is open ──────────────────────────────────────

    // CAR buttons: 2 columns x 2 rows starting at (OVL_X+8, OVL_Y+32)
    for (int i = 0; i < NUM_CARS; i++) {
      int col = i % 2;
      int row = i / 2;
      int bx = OVL_X + 8 + col * (OVL_BTN_W + 6);
      int by = OVL_Y + 32 + row * (OVL_BTN_H + 6);
      if (tx >= bx && tx <= bx + OVL_BTN_W &&
          ty >= by && ty <= by + OVL_BTN_H) {
        selectedCar = i;
        drawMenuOverlay();
        return;
      }
    }

    // BG buttons: same grid but offset to bgSectionY
    int bgSectionY = OVL_Y + 32 + 2 * (OVL_BTN_H + 6) + 8;
    for (int i = 0; i < NUM_BGS; i++) {
      int col = i % 2;
      int row = i / 2;
      int bx = OVL_X + 8 + col * (OVL_BTN_W + 6);
      int by = bgSectionY + 12 + row * (OVL_BTN_H + 6);
      if (tx >= bx && tx <= bx + OVL_BTN_W &&
          ty >= by && ty <= by + OVL_BTN_H) {
        selectedBg = i;
        drawMenuOverlay();
        return;
      }
    }

    // CLOSE button
    int closeY = OVL_Y + OVL_H - 30;
    if (tx >= OVL_X + 80 && tx <= OVL_X + 220 &&
        ty >= closeY && ty <= closeY + 24) {
      closeMenu();
    }
  }
}

“Deploy to device” opens this project in Schematik, where you can flash it to your board over USB.

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