Community project

ESP32 Snake Game

ESP32
Photo of ESP32 Snake Game

Tucker

Published September 3, 2026

This guide builds a playable Snake game on an ESP32 microcontroller with a color touchscreen display. The game runs locally on the device and includes customizable features like adjustable snake appearance, background colors, and control settings all accessible through an on-screen menu system.

The project uses an ESP32 development board paired with a TFT display, powered via USB. This guide provides the complete wiring diagram, parts list, and firmware code. Setup involves connecting the display, powering the board, entering Wi-Fi credentials on screen, and adjusting display brightness to get started playing.

Wiring diagram

Assemble it in 4 steps

1. Keep the screen connections as they are

Use the ESP32-3248S035C board with its built-in screen and touch panel. Do not add wires to GPIO32 or GPIO33 because those pins are already used inside the board for touch.

  • The brightness and Wi-Fi controls use the board’s built-in hardware, so no extra module is needed.
  • Adding wires to GPIO32 or GPIO33 can stop the touch screen from responding.

2. Use USB power

Plug the board into a USB power source or your computer with a USB cable. The screen should turn on; its saved brightness is restored when the app starts.

  • If the display seems dark, open Settings and tap farther to the right on the brightness bar.
  • Do not force a plug into the board; forcing it can damage the connector.

3. Enter Wi-Fi details on the screen

Open the Settings app, tap WI-FI CONNECTION, tap the top box for your network name, then tap the lower box for its password. Use the on-screen keys and tap CONNECT.

  • The password shows as stars so someone looking at the screen cannot read it.
  • If your Wi-Fi name contains lowercase letters, enter capitals because the on-screen keyboard currently uses capital letters.
  • A wrong network name or password prevents the board from joining Wi-Fi, but it does not damage the board.

4. Adjust the screen brightness

Return to Settings and tap the brightness bar: farther right makes the screen brighter and farther left makes it dimmer. The chosen level is remembered after power is turned off.

  • Set a comfortable brightness before playing games for a long time.
  • Setting the bar very far left can make the display hard to see; tap farther right to recover.

Deploy the firmware

#include <Arduino.h>
#include <Wire.h>
#include <TFT_eSPI.h>
#include <Preferences.h>
#include <SPI.h>
#include <SD.h>
#include <WiFi.h>

// Pixel Studio for ESP32-3248S035C. The display is updated locally while painting.

enum Page { HOME_PAGE, GALLERY_PAGE, SIZE_PAGE, STUDIO_PAGE, COLOUR_PAGE, PIXEL_GAME_PAGE, SNAKE_PAGE, SNAKE_HEAD_PAGE, SNAKE_HEAD_COLOUR_PAGE, SNAKE_BACKGROUND_PAGE, SNAKE_CONTROL_PAGE, SKIN_PAGE, SKIN_COLOUR_PAGE, FLAPPY_PAGE, BIRD_EDITOR_PAGE, BIRD_COLOUR_PAGE, BIRD_IMPORT_PAGE, ART_NAME_PAGE, CALCULATOR_PAGE, RESISTOR_PAGE, CHESS_PAGE, CHECKERS_PAGE, SUDOKU_PAGE, STORAGE_PAGE, SETTINGS_PAGE, WIFI_PAGE }; 


// Forward declarations

// Forward declarations

// Forward declarations

// Forward declarations

// Forward declarations
void scanWifiNetworks();
void drawWifiFields();
void drawWifiKey(int keyIndex, bool selected);
void drawWifiApp();
void tapWifiApp(int x, int y);
void updateWifiConnection();

bool chessWhite(char piece);

String nameKey(int slot);
String slotName(int slot);

bool inside(int x, int y, int w, int h, int tx, int ty);
bool gtRead(uint16_t reg, uint8_t *data, uint8_t count);
void gtWrite(uint16_t reg, uint8_t value);
bool readTouch(int &x, int &y);
int readTouches(int xs[2], int ys[2]);
uint16_t paletteColour(int index);
uint16_t palette100Colour(int index);
void label(const String &value, int x, int y, int font, uint16_t fg, uint16_t bg);
void button(int x, int y, int w, int h, const String &value, uint16_t fill, int font);
String artKey(int slot);
String sizeKey(int slot);
bool slotExists(int slot);
void setGeometry();
void clearArt();
void drawHome();
void drawAllAppsHome();
void drawHomeSecondPage();
void drawSizePage();
void drawCell(int index);
void drawCanvas();
void drawStudioHeader();
void drawTools();
void resetZoom();
void zoomAt(float newZoom, int focusX, int focusY);
void drawStudio();
void drawThumbnail(int slot, int x, int y, int w, int h);
void drawGallery();
void drawArtNamePage();
void drawArtNameDisplay();
void drawArtNameKey(int keyIndex, bool selected);
void drawColourPage();
void drawPixelGame();
void drawGameCell(int cell, uint16_t colour);
void spawnGamePixel();
void tapPixelGame(int x, int y);
void drawSnakeGame();
void drawSnakeScene();
void drawSnakeBackgroundPage();
uint16_t snakeBackgroundColour(int col, int row);
void drawSnakeBackgroundCell(int col, int row);
void drawSnakeHeadEditor();
void drawSnakeHeadColourPage();
void drawSnakeControlPage();
void drawSnakeControls();
void setSnakeDirection(int dx, int dy);
void drawSnakeCell(int col, int row, uint16_t colour);
void drawSnakeBodyCell(int col, int row);
void drawSnakeHead(int col, int row);
void updatePaletteSelection(int oldIndex, int newIndex, int y0, bool rounded);
void saveSnakeHead();
void tapSnakeHeadEditor(int x, int y);
void tapSnakeHeadColourPage(int x, int y);
void spawnSnakeFood();
void spawnMysteryBox();
void updateSnake();
void tapSnake(int x, int y);
void copyArt(uint16_t *destination, const uint16_t *source);
void pushUndo();
void undoAction();
void redoAction();
void showSavedNotice();
void saveArt();
void chooseSize(int newSize);
void openSlot(int slot);
void tapStudio(int x, int y);
void tapGallery(int x, int y);
void tapArtNamePage(int x, int y);
void tapColour(int x, int y);
void handleTap(int x, int y);
void migrateOlderSavedDrawing();
String sdArtPath(int slot);
bool sdArtExists(int slot);
void saveArtToCard(int slot);
bool loadArtFromCard(int slot, uint8_t expectedSize);
void drawSkinMaker();
void drawSkinCell(int index);
void drawSkinColours();
void saveSkin();
void tapSkinMaker(int x, int y);
void tapSkinColours(int x, int y);
void drawFlappyGame();
void drawFlappyBird(int x, int y, uint16_t background);
void drawFlappyPipe(int x, int gapY);
void drawFlappyCoin(int x, int y);
void drawFlappyPowerUp(int x, int y, int type);
void restoreFlappyBackgroundStrip(int x, int w);
void updateFlappyGame();
void tapFlappyGame(int x, int y);
void drawBirdEditor();
void drawBirdColourPage();
void tapBirdEditor(int x, int y);
void tapBirdColourPage(int x, int y);
void saveBirdSkin();
void drawBirdImportPage();
void importSavedArtToBird(int slot);
void tapBirdImportPage(int x, int y);
void drawCalculator();
void drawCalculatorDisplay();
void tapCalculator(int x, int y);
void drawResistorCalculator();
void drawResistorValue();
void tapResistorCalculator(int x, int y);
void drawChessGame();
void drawChessPiece(int col, int row);
void drawChessStatus();
bool chessMoveIsLegal(int fromCol, int fromRow, int toCol, int toRow);
void chessMakeAiMove();
void tapChessGame(int x, int y);
void drawCheckersGame();
void drawCheckersPiece(int col, int row);
void drawCheckersStatus();
bool checkersMoveIsLegal(int fromCol, int fromRow, int toCol, int toRow);
void checkersMakeAiMove();
void tapCheckersGame(int x, int y);
void drawSudokuGame();
void sudokuNewPuzzle();
void drawSudokuCell(int col, int row);
void drawSudokuStatus();
void tapSudokuGame(int x, int y);
String storageSizeText(uint64_t bytes);
void drawStorageApp();
void tapStorageApp(int x, int y);
void drawSettingsApp();
void tapSettingsApp(int x, int y);
void drawWifiApp();
void tapWifiApp(int x, int y);
void drawWifiFields();
void drawWifiKey(int keyIndex, bool selected);
void scanWifiNetworks();
void updateWifiConnection();

TFT_eSPI tft;
Preferences prefs;
bool sdCardReady = false;
const int SD_CS_PIN = 5;
const int SD_SCK_PIN = 18;
const int SD_MISO_PIN = 19;
const int SD_MOSI_PIN = 23;

const int SCREEN_W = 320;
const int SCREEN_H = 480;
const int TOUCH_SDA_PIN = 33;
const int TOUCH_SCL_PIN = 32;
const int TOUCH_RESET_PIN = 25;
const int TFT_BACKLIGHT_PIN = 27;
uint8_t screenBrightness = 220; // 0 is dimmest and 255 is brightest.
const uint8_t TOUCH_ADDRESS = 0x5D;
const int MAX_PIXELS = 32 * 32;
const int HISTORY_DEPTH = 12;
const int SAVE_SLOTS = 12;
const int GALLERY_SLOTS_PER_PAGE = 6;
const int PALETTE_COUNT = 160;
const int PALETTE_COLUMNS = 16;
const int PALETTE_ROWS = 10;
const int SKIN_COLS = 16;
const int SKIN_ROWS = 32;
const int SKIN_CELL = 9;
const int SKIN_X = 88;
const int SKIN_Y = 76;
const int SKIN_PIXELS = SKIN_COLS * SKIN_ROWS;
uint16_t skin[SKIN_PIXELS];
uint8_t skinColour = 70;


Page page = HOME_PAGE;
// The launcher has two pages. A sideways finger slide changes pages without
// opening an icon until the finger is lifted.
uint8_t homeAppPage = 0;
int homeTouchStartX = 0;
int homeTouchStartY = 0;
bool homeSwipeMoved = false;
int homeSwipeDirection = 0;
unsigned long homeTouchMissingAt = 0;

uint16_t art[MAX_PIXELS];
uint16_t undoHistory[HISTORY_DEPTH][MAX_PIXELS];
uint16_t redoHistory[HISTORY_DEPTH][MAX_PIXELS];
int undoCount = 0;
int redoCount = 0;
int gridSize = 16;
int cellSize = 18;
int canvasX = 16;
int canvasY = 76;
int canvasSize = 288;
float canvasZoom = 1.0f;
int canvasPanX = 0;
int canvasPanY = 0;
float pinchStartDistance = 0.0f;
float pinchStartZoom = 1.0f;
bool pinchActive = false;
int activeSlot = -1;
int galleryPage = 0;
bool galleryDeleteMode = false;
String artNameBuffer = "";
// The name keyboard keeps its layout on screen; this remembers the one key outlined last.
int lastArtNameKey = -1;
// GT911 touch reports can briefly drop while a finger is still resting on glass.
// This blocks repeated letters from one press without slowing different keys.
int heldArtNameKey = -1;
unsigned long artNameKeyReleaseAt = 0;
uint8_t selectedColour = 70;
bool eraser = false;
bool eyedropper = false;
bool touchWasDown = false;

// Pixel Pop is a small reaction game: tap the coloured pixel before it moves.
const int GAME_GRID = 8;
const int GAME_CELL = 34;
const int GAME_X = 24;
const int GAME_Y = 118;
int gamePixel = -1;
uint16_t gameColour = TFT_YELLOW;
int gameScore = 0;
int gameBest = 0;

// Snake: 25 by 25 cells. Only changed cells, food, and status panels are redrawn.
const int SNAKE_COLS = 25;
const int SNAKE_ROWS = 25;
const int SNAKE_CELL = 12;
const int SNAKE_X = 10;
const int SNAKE_Y = 78;
const int SNAKE_MAX = SNAKE_COLS * SNAKE_ROWS;
int snakeX[SNAKE_MAX], snakeY[SNAKE_MAX];
int snakeLength = 5;
int snakeDirX = 1, snakeDirY = 0;
int snakeNextX = 1, snakeNextY = 0;
int snakeFoodX = 18, snakeFoodY = 12;
int mysteryX = -1, mysteryY = -1;
// Mystery boxes can grow the snake, add points, or briefly make it move faster.
int mysteryType = 0;
int snakeTurboMoves = 0;
int snakeScore = 0, snakeBest = 0, foodsEaten = 0;
// Extra length is added one connected square at a time, so no old body squares are left behind.
int snakePendingGrowth = 0;
// The snake head is a separately editable 8 by 8 pixel picture.
uint16_t snakeHeadSkin[64];
uint8_t snakeHeadColour = 70;
// The selected board background is saved and every cleared snake cell is repainted with it.
uint8_t snakeBackground = 0;
unsigned long snakeLastMove = 0;
bool snakeOver = false;
// 0 = separate arrow pads, 1 = touch joystick, 2 = swipe anywhere on the board.
uint8_t snakeControlMode = 0;
int snakeSwipeStartX = 0, snakeSwipeStartY = 0;
bool snakeSwipeTracking = false;

// Flappy Bird redraws only the bird, the old/new pipe strips, and score card.
const int FLAPPY_TOP = 66;
const int FLAPPY_GROUND = 397;
const int FLAPPY_PIPE_W = 52;
const int FLAPPY_GAP_H = 112;
const int FLAPPY_MIN_GAP_H = 72;
int flappyBirdX = 78, flappyBirdY = 210, flappyOldY = 210;
float flappyVelocityY = 0;
int flappyPipeX = 270, flappyGapY = 202;
int flappyCoinX = 296, flappyCoinY = 202;
// One collectible power-up travels with each pipe: star = points, clock = slow pipes, shield = one safe collision.
int flappyPowerX = 281, flappyPowerY = 202, flappyPowerType = 0;
bool flappyPipePassed = false;
bool flappyCoinCollected = false, flappyPowerCollected = false;
int flappySlowFrames = 0;
bool flappyShield = false;
int flappyScore = 0, flappyBest = 0;
unsigned long flappyLastFrame = 0;
bool flappyRunning = false;
bool flappyOver = false;
// The Flappy bird is an editable 8 by 8 pixel picture, saved in board memory.
uint16_t birdSkin[64];
uint8_t birdColour = 20;

// Wi-Fi support remains inactive; the Settings screen no longer opens it.
String wifiSsid = "";
String wifiPassword = "";
uint8_t wifiEditingField = 1;
int lastWifiKey = -1;
bool wifiShift = false;
bool wifiKeyHeld = false;
unsigned long wifiKeyReleaseAt = 0;
String wifiMessage = "";
bool wifiConnecting = false;
unsigned long wifiConnectStartedAt = 0;

// Calculator keeps its keypad still; only this display panel changes after a tap.
String calculatorEntry = "0";
float calculatorValue = 0.0f;
char calculatorOperation = 0;
bool calculatorStartNewNumber = true;
// The touch controller can briefly report a release while a finger is still down.
// Keep a calculator key locked until there has been a real release.
bool calculatorKeyHeld = false;
unsigned long calculatorKeyReleaseAt = 0;

// Resistor reader supports standard 4-band and precision 5-band resistors.
// Four bands use two digits, multiplier, tolerance. Five bands use three digits,
// multiplier, tolerance.
const uint16_t resistorColours[10] = { TFT_BLACK, TFT_BROWN, TFT_RED, TFT_ORANGE, TFT_YELLOW, TFT_GREEN, TFT_BLUE, TFT_PURPLE, TFT_DARKGREY, TFT_WHITE };
const char *resistorColourNames[10] = { "BLACK", "BROWN", "RED", "ORANGE", "YELLOW", "GREEN", "BLUE", "VIOLET", "GREY", "WHITE" };
int resistorBand[5] = { 1, 0, 0, 2, 5 }; // 5-band default: brown-black-black-red-green = 10 kΩ ±0.5%.
int resistorBandCount = 4;
int resistorEditingBand = 0;

// Chess is a two-player pass-and-play board. Pieces are stored as uppercase
// white and lowercase black letters, making captures and turns unambiguous.
const int CHESS_X = 32;
const int CHESS_Y = 92;
const int CHESS_CELL = 32;
char chessBoard[8][8] = {
  {'r','n','b','q','k','b','n','r'},
  {'p','p','p','p','p','p','p','p'},
  {' ',' ',' ',' ',' ',' ',' ',' '},
  {' ',' ',' ',' ',' ',' ',' ',' '},
  {' ',' ',' ',' ',' ',' ',' ',' '},
  {' ',' ',' ',' ',' ',' ',' ',' '},
  {'P','P','P','P','P','P','P','P'},
  {'R','N','B','Q','K','B','N','R'}
};
bool chessWhiteTurn = true;
// Two-player mode lets people take both sides. In AI mode, the player is White
// and the board immediately makes a legal Black reply.
bool chessAiMode = false;
int chessSelectedCol = -1;
int chessSelectedRow = -1;
String chessMessage = "White to move";

// Checkers is a two-player pass-and-play game. Uppercase pieces are red and
// lowercase pieces are blue; K/k means that piece has reached king status.
char checkersBoard[8][8] = {
  {' ','b',' ','b',' ','b',' ','b'},
  {'b',' ','b',' ','b',' ','b',' '},
  {' ','b',' ','b',' ','b',' ','b'},
  {' ',' ',' ',' ',' ',' ',' ',' '},
  {' ',' ',' ',' ',' ',' ',' ',' '},
  {'r',' ','r',' ','r',' ','r',' '},
  {' ','r',' ','r',' ','r',' ','r'},
  {'r',' ','r',' ','r',' ','r',' '}
};
bool checkersRedTurn = true;
bool checkersAiMode = false;
int checkersSelectedCol = -1;
int checkersSelectedRow = -1;
String checkersMessage = "Red to move";

// Sudoku starts from a valid pattern, then safely shuffles its digits, rows, and
// columns for a different correct puzzle each time. The difficulty changes how
// many starting numbers remain visible.
const int SUDOKU_X = 25;
const int SUDOKU_Y = 78;
const int SUDOKU_CELL = 30;
const uint8_t sudokuBase[9][9] = {
  {5,3,4,6,7,8,9,1,2}, {6,7,2,1,9,5,3,4,8}, {1,9,8,3,4,2,5,6,7},
  {8,5,9,7,6,1,4,2,3}, {4,2,6,8,5,3,7,9,1}, {7,1,3,9,2,4,8,5,6},
  {9,6,1,5,3,7,2,8,4}, {2,8,7,4,1,9,6,3,5}, {3,4,5,2,8,6,1,7,9}
};
uint8_t sudokuStart[9][9];
uint8_t sudokuSolution[9][9];
uint8_t sudokuBoard[9][9];
uint8_t sudokuDifficulty = 1; // 0 easy, 1 medium, 2 hard.
int sudokuSelectedCol = -1;
int sudokuSelectedRow = -1;
String sudokuMessage = "Tap an empty square";

bool inside(int x, int y, int w, int h, int tx, int ty) {
  return tx >= x && tx < x + w && ty >= y && ty < y + h;
}

bool gtRead(uint16_t reg, uint8_t *data, uint8_t count) {
  Wire.beginTransmission(TOUCH_ADDRESS);
  Wire.write(reg >> 8); Wire.write(reg & 0xFF);
  if (Wire.endTransmission(false) != 0) return false;
  if (Wire.requestFrom((int)TOUCH_ADDRESS, (int)count) != count) return false;
  for (uint8_t i = 0; i < count; i++) data[i] = Wire.read();
  return true;
}

void gtWrite(uint16_t reg, uint8_t value) {
  Wire.beginTransmission(TOUCH_ADDRESS);
  Wire.write(reg >> 8); Wire.write(reg & 0xFF); Wire.write(value);
  Wire.endTransmission();
}

int readTouches(int xs[2], int ys[2]) {
  uint8_t status;
  if (!gtRead(0x814E, &status, 1) || !(status & 0x80)) return 0;
  int count = status & 0x0F;
  if (count > 2) count = 2;
  uint8_t points[8];
  bool received = count > 0 && gtRead(0x8150, points, count * 4);
  gtWrite(0x814E, 0);
  if (!received) return 0;
  for (int i = 0; i < count; i++) {
    xs[i] = constrain(points[i * 4] | (points[i * 4 + 1] << 8), 0, SCREEN_W - 1);
    ys[i] = constrain(points[i * 4 + 2] | (points[i * 4 + 3] << 8), 0, SCREEN_H - 1);
  }
  return count;
}

bool readTouch(int &x, int &y) {
  int xs[2], ys[2];
  if (!readTouches(xs, ys)) return false;
  x = xs[0]; y = ys[0];
  return true;
}

uint16_t paletteColour(int index) {
  // Fifteen full-colour families stay in place. The last column is a separate
  // black-to-white scale, with black at the bottom and white at the top.
  index = constrain(index, 0, PALETTE_COUNT - 1);
  int column = index % PALETTE_COLUMNS;
  int row = index / PALETTE_COLUMNS;
  if (column == 15) {
    uint8_t shade = (uint8_t)(255 - row * 28);
    return tft.color565(shade, shade, shade);
  }
  const uint8_t base[15][3] = {
    {255, 55, 55}, {255, 110, 35}, {255, 185, 25}, {255, 235, 40}, {170, 235, 40},
    {45, 220, 75}, {25, 210, 155}, {25, 205, 225}, {35, 150, 255}, {65, 85, 245},
    {125, 65, 240}, {190, 60, 235}, {245, 65, 175}, {245, 110, 145}, {210, 145, 85}
  };
  float level = 1.0f - row * 0.085f;
  return tft.color565((uint8_t)(base[column][0] * level),
                      (uint8_t)(base[column][1] * level),
                      (uint8_t)(base[column][2] * level));
}

uint16_t palette100Colour(int index) {
  // Legacy name used by the game editors; it supplies the same 160-colour palette.
  return paletteColour(constrain(index, 0, PALETTE_COUNT - 1));
}

// Colour pickers keep their screen still: only the old and new swatches redraw.
void updatePaletteSelection(int oldIndex, int newIndex, int y0, bool rounded) {
  int choices[2] = { oldIndex, newIndex };
  for (int i = 0; i < 2; i++) {
    int n = choices[i];
    int x = (n % PALETTE_COLUMNS) * 20;
    int y = y0 + (n / PALETTE_COLUMNS) * 20;
    uint16_t c = paletteColour(n);
    if (rounded) tft.fillRoundRect(x + 1, y + 1, 17, 17, 3, c);
    else tft.fillRect(x, y, 19, 19, c);
    tft.drawRoundRect(x, y, 19, 19, 3, n == newIndex ? TFT_RED : TFT_BLACK);
  }
}

void label(const String &value, int x, int y, int font, uint16_t fg, uint16_t bg) {
  tft.setTextDatum(MC_DATUM);
  tft.setTextColor(fg, bg);
  tft.drawString(value, x, y, font);
}

void button(int x, int y, int w, int h, const String &value, uint16_t fill, int font = 2) {
  tft.fillRoundRect(x + 3, y + 4, w, h, 10, TFT_DARKGREY);
  tft.fillRoundRect(x, y, w, h, 10, fill);
  tft.drawRoundRect(x, y, w, h, 10, TFT_WHITE);
  if (w > 24) tft.drawFastHLine(x + 9, y + 6, w - 18, TFT_YELLOW);
  label(value, x + w / 2, y + h / 2 + 1, font, TFT_WHITE, fill);
}

String artKey(int slot) { return String("art") + String(slot); }
String sizeKey(int slot) { return String("sz") + String(slot); }
String nameKey(int slot) { return String("nm") + String(slot); }
String slotName(int slot) {
  String saved = prefs.getString(nameKey(slot).c_str(), "");
  return saved.length() ? saved : String("Drawing ") + String(slot + 1);
}

String sdArtPath(int slot) { return String("/pixel_art_") + String(slot) + ".bin"; }

bool sdArtExists(int slot) {
  return sdCardReady && SD.exists(sdArtPath(slot).c_str());
}

bool slotExists(int slot) {
  if (sdArtExists(slot)) return true;
  String key = artKey(slot);
  return prefs.isKey(key.c_str()) && prefs.getUChar(sizeKey(slot).c_str(), 0) > 0;
}

// Each file begins with the canvas size, followed by its RGB565 pixel colours.
void saveArtToCard(int slot) {
  if (!sdCardReady) return;
  String path = sdArtPath(slot);
  if (SD.exists(path.c_str())) SD.remove(path.c_str());
  File file = SD.open(path.c_str(), FILE_WRITE);
  if (!file) return;
  uint8_t savedSize = (uint8_t)gridSize;
  file.write(&savedSize, 1);
  file.write((const uint8_t *)art, gridSize * gridSize * sizeof(uint16_t));
  file.close();
}

bool loadArtFromCard(int slot, uint8_t expectedSize) {
  if (!sdArtExists(slot)) return false;
  File file = SD.open(sdArtPath(slot).c_str(), FILE_READ);
  if (!file) return false;
  uint8_t storedSize = 0;
  bool valid = file.read(&storedSize, 1) == 1 && storedSize == expectedSize;
  size_t bytes = expectedSize * expectedSize * sizeof(uint16_t);
  if (valid) valid = file.read((uint8_t *)art, bytes) == bytes;
  file.close();
  return valid;
}

void setGeometry() {
  canvasSize = 288;
  canvasX = 16;
  canvasY = 76;
  cellSize = canvasSize / gridSize;
  resetZoom();
}

void resetZoom() {
  canvasZoom = 1.0f;
  canvasPanX = 0;
  canvasPanY = 0;
  pinchActive = false;
}

void zoomAt(float newZoom, int focusX, int focusY) {
  newZoom = constrain(newZoom, 1.0f, 4.0f);
  float oldScale = cellSize * canvasZoom;
  float artX = (focusX - canvasX + canvasPanX) / oldScale;
  float artY = (focusY - canvasY + canvasPanY) / oldScale;
  canvasZoom = newZoom;
  float newScale = cellSize * canvasZoom;
  int fullSize = (int)(gridSize * newScale);
  canvasPanX = constrain((int)(artX * newScale - (focusX - canvasX)), 0, max(0, fullSize - canvasSize));
  canvasPanY = constrain((int)(artY * newScale - (focusY - canvasY)), 0, max(0, fullSize - canvasSize));
}

void clearArt() {
  for (int i = 0; i < MAX_PIXELS; i++) art[i] = TFT_BLACK;
}

void drawHome() {
  page = HOME_PAGE;
  if (homeAppPage == 0) drawAllAppsHome();
  else drawHomeSecondPage();
  return;
  // Legacy launcher code is kept below but is no longer reached.
  // Paint directly over the old launcher so changing pages has no blank flash.
  tft.fillRect(0, 0, SCREEN_W, SCREEN_H, TFT_BLACK);
  tft.fillRect(0, 0, SCREEN_W, 82, TFT_NAVY);
  label("PIXEL STUDIO", 145, 25, 4, TFT_WHITE, TFT_NAVY);
  label("Create, save, and play", 132, 57, 2, TFT_CYAN, TFT_NAVY);
  if (homeAppPage == 1) {
    drawHomeSecondPage();
    return;
  }
  label("Swipe left for more apps", 160, 469, 1, TFT_CYAN, TFT_BLACK);

  // Large glossy phone-style icons. Their touch areas match their full cards.
  tft.fillRoundRect(20, 105, 130, 130, 28, TFT_DARKGREEN);
  tft.drawRoundRect(20, 105, 130, 130, 28, TFT_WHITE);
  tft.drawRoundRect(24, 109, 122, 122, 24, TFT_YELLOW);
  tft.fillRoundRect(31, 116, 108, 108, 22, TFT_GREEN);
  tft.fillRoundRect(40, 125, 90, 25, 12, TFT_GREEN);
  tft.fillCircle(85, 177, 28, TFT_DARKGREEN);
  label("+", 85, 177, 7, TFT_WHITE, TFT_DARKGREEN);
  label("New Art", 85, 255, 2, TFT_WHITE, TFT_BLACK);

  tft.fillRoundRect(170, 105, 130, 130, 28, TFT_BLUE);
  tft.drawRoundRect(170, 105, 130, 130, 28, TFT_WHITE);
  tft.drawRoundRect(174, 109, 122, 122, 24, TFT_YELLOW);
  tft.fillRoundRect(181, 116, 108, 108, 22, TFT_CYAN);
  tft.fillRoundRect(190, 125, 90, 18, 9, TFT_LIGHTGREY);
  tft.fillRect(197, 153, 31, 31, TFT_YELLOW);
  tft.fillRect(233, 153, 31, 31, TFT_MAGENTA);
  tft.fillRect(197, 189, 31, 25, TFT_ORANGE);
  tft.fillRect(233, 189, 31, 25, TFT_GREEN);
  label("Gallery", 235, 255, 2, TFT_WHITE, TFT_BLACK);

  // Two matching game cards on the lower row keep the launcher easy to use.
  tft.fillRoundRect(20, 288, 130, 130, 28, TFT_DARKGREEN);
  tft.drawRoundRect(20, 288, 130, 130, 28, TFT_WHITE);
  tft.drawRoundRect(24, 292, 122, 122, 24, TFT_YELLOW);
  tft.fillRoundRect(31, 299, 108, 108, 22, TFT_GREEN);
  tft.fillCircle(67, 353, 21, TFT_NAVY);
  tft.fillCircle(94, 353, 16, TFT_NAVY);
  tft.fillCircle(116, 353, 13, TFT_NAVY);
  tft.fillCircle(61, 347, 4, TFT_WHITE);
  tft.fillCircle(61, 347, 2, TFT_BLACK);
  tft.drawLine(49, 364, 40, 372, TFT_RED);
  label("Snake", 85, 438, 2, TFT_WHITE, TFT_BLACK);

  tft.fillRoundRect(170, 288, 130, 130, 28, TFT_SKYBLUE);
  tft.drawRoundRect(170, 288, 130, 130, 28, TFT_WHITE);
  tft.drawRoundRect(174, 292, 122, 122, 24, TFT_YELLOW);
  tft.fillRoundRect(181, 299, 108, 108, 22, TFT_CYAN);
  // Flappy Bird icon: a bird flying between two bright green pipes.
  tft.fillRect(191, 307, 22, 38, TFT_DARKGREEN);
  tft.fillRect(191, 307, 26, 8, TFT_GREEN);
  tft.fillRect(258, 358, 22, 38, TFT_DARKGREEN);
  tft.fillRect(254, 381, 26, 8, TFT_GREEN);
  tft.fillCircle(235, 348, 17, TFT_YELLOW);
  tft.fillCircle(240, 342, 5, TFT_WHITE);
  tft.fillCircle(241, 342, 2, TFT_BLACK);
  tft.fillTriangle(250, 348, 261, 353, 250, 358, TFT_ORANGE);
  label("Flappy Bird", 235, 438, 2, TFT_WHITE, TFT_BLACK);
}

void drawAllAppsHome() {
  // One calm launcher page: seven compact, evenly spaced app icons.
  tft.fillRect(0, 0, SCREEN_W, SCREEN_H, TFT_BLACK);
  tft.fillRect(0, 0, SCREEN_W, 62, TFT_NAVY);
  label("PIXEL STUDIO", 160, 23, 4, TFT_WHITE, TFT_NAVY);
  label("Apps 1 of 2", 160, 48, 2, TFT_CYAN, TFT_NAVY);
  button(268, 9, 44, 42, ">", TFT_BLUE, 4);

  const int w = 92, h = 83;
  const int xs[3] = { 10, 114, 218 };
  const int ys[2] = { 78, 177 };
  const char *names[6] = { "New Art", "Gallery", "Snake", "Flappy", "Calc", "Ohms" };
  const uint16_t fills[6] = { TFT_DARKGREEN, TFT_BLUE, TFT_GREEN, TFT_SKYBLUE, TFT_ORANGE, TFT_BROWN };
  for (int i = 0; i < 6; i++) {
    int x = xs[i % 3], y = ys[i / 3];
    int cx = x + w / 2, cy = y + 34;
    tft.fillRoundRect(x + 2, y + 4, w, h, 18, TFT_DARKGREY);
    tft.fillRoundRect(x, y, w, h, 18, fills[i]);
    tft.drawRoundRect(x, y, w, h, 18, TFT_WHITE);
    tft.drawRoundRect(x + 3, y + 3, w - 6, h - 6, 15, TFT_YELLOW);
    // Each app has a picture logo instead of letters inside its icon.
    if (i == 0) { // paint brush and coloured paint dots
      tft.fillRoundRect(cx - 18, cy - 5, 31, 11, 5, tft.color565(236, 188, 110));
      tft.fillCircle(cx + 15, cy, 6, TFT_BROWN);
      tft.fillTriangle(cx - 24, cy - 11, cx - 14, cy - 5, cx - 23, cy + 1, TFT_LIGHTGREY);
      tft.fillCircle(cx - 10, cy - 17, 5, TFT_RED); tft.fillCircle(cx + 2, cy - 19, 5, TFT_CYAN); tft.fillCircle(cx + 13, cy - 14, 5, TFT_YELLOW);
    } else if (i == 1) { // landscape photo
      tft.fillRoundRect(cx - 27, cy - 21, 54, 42, 5, TFT_WHITE);
      tft.fillRect(cx - 23, cy - 17, 46, 34, TFT_SKYBLUE);
      tft.fillCircle(cx + 12, cy - 9, 5, TFT_YELLOW);
      tft.fillTriangle(cx - 22, cy + 15, cx - 5, cy - 5, cx + 9, cy + 15, TFT_GREEN);
      tft.fillTriangle(cx - 4, cy + 15, cx + 11, cy - 1, cx + 23, cy + 15, TFT_DARKGREEN);
    } else if (i == 2) { // curled snake with face
      tft.fillCircle(cx - 15, cy + 3, 11, TFT_DARKGREEN); tft.fillCircle(cx - 2, cy + 4, 13, TFT_DARKGREEN);
      tft.fillCircle(cx + 13, cy - 4, 15, TFT_DARKGREEN); tft.fillCircle(cx + 18, cy - 10, 3, TFT_WHITE);
      tft.fillCircle(cx + 18, cy - 10, 1, TFT_BLACK); tft.drawLine(cx + 26, cy + 3, cx + 33, cy + 8, TFT_RED);
    } else if (i == 3) { // flying bird and pipes
      tft.fillRect(cx - 29, cy - 21, 10, 20, TFT_DARKGREEN); tft.fillRect(cx - 31, cy - 21, 14, 5, TFT_GREEN);
      tft.fillRect(cx + 20, cy + 3, 10, 20, TFT_DARKGREEN); tft.fillRect(cx + 18, cy + 18, 14, 5, TFT_GREEN);
      tft.fillCircle(cx, cy, 12, TFT_YELLOW); tft.fillCircle(cx + 4, cy - 4, 3, TFT_WHITE); tft.fillCircle(cx + 5, cy - 4, 1, TFT_BLACK);
      tft.fillTriangle(cx + 10, cy, cx + 19, cy + 4, cx + 10, cy + 8, TFT_ORANGE);
    } else if (i == 4) { // calculator
      tft.fillRoundRect(cx - 22, cy - 25, 44, 50, 6, TFT_DARKGREY); tft.drawRoundRect(cx - 22, cy - 25, 44, 50, 6, TFT_WHITE);
      tft.fillRect(cx - 16, cy - 19, 32, 11, TFT_CYAN);
      for (int r = 0; r < 3; r++) for (int c = 0; c < 3; c++) tft.fillCircle(cx - 10 + c * 10, cy + r * 9, 3, TFT_LIGHTGREY);
      tft.fillCircle(cx + 10, cy + 18, 3, TFT_ORANGE);
    } else { // resistor with coloured bands
      tft.fillRoundRect(cx - 28, cy - 10, 56, 20, 9, tft.color565(230, 195, 135));
      tft.drawFastHLine(cx - 39, cy, 11, TFT_LIGHTGREY); tft.drawFastHLine(cx + 28, cy, 11, TFT_LIGHTGREY);
      tft.fillRect(cx - 13, cy - 10, 5, 20, TFT_BROWN); tft.fillRect(cx - 2, cy - 10, 5, 20, TFT_RED);
      tft.fillRect(cx + 9, cy - 10, 5, 20, TFT_GOLD); tft.fillRect(cx + 19, cy - 10, 5, 20, TFT_GREEN);
    }
    label(names[i], x + w / 2, y + 68, 1, TFT_WHITE, fills[i]);
  }
  tft.fillRoundRect(10 + 2, 286 + 4, 92, 83, 18, TFT_DARKGREY);
  tft.fillRoundRect(10, 286, 92, 83, 18, TFT_MAROON);
  tft.drawRoundRect(10, 286, 92, 83, 18, TFT_WHITE);
  tft.drawRoundRect(13, 289, 86, 77, 15, TFT_YELLOW);
  for (int r = 0; r < 4; r++) for (int c = 0; c < 4; c++)
    tft.fillRect(31 + c * 10, 301 + r * 10, 9, 9, ((r + c) & 1) ? tft.color565(104, 72, 46) : tft.color565(237, 214, 177));
  tft.fillCircle(48, 320, 8, TFT_RED);
  tft.fillCircle(63, 336, 8, TFT_BLUE);
  label("Checkers", 56, 354, 1, TFT_WHITE, TFT_MAROON);

  // Sudoku gets its own picture logo: a numbered puzzle grid, not a letter icon.
  tft.fillRoundRect(114 + 2, 286 + 4, 92, 83, 18, TFT_DARKGREY);
  tft.fillRoundRect(114, 286, 92, 83, 18, TFT_DARKGREEN);
  tft.drawRoundRect(114, 286, 92, 83, 18, TFT_WHITE);
  tft.drawRoundRect(117, 289, 86, 77, 15, TFT_YELLOW);
  for (int r = 0; r < 4; r++) for (int c = 0; c < 4; c++) {
    int gx = 133 + c * 10, gy = 300 + r * 10;
    tft.fillRect(gx, gy, 8, 8, TFT_WHITE);
    if ((r + c) % 3 == 0) label(String((r * 4 + c) % 9 + 1), gx + 4, gy + 4, 1, TFT_NAVY, TFT_WHITE);
  }
  label("Sudoku", 160, 354, 1, TFT_WHITE, TFT_DARKGREEN);

  tft.fillRoundRect(218 + 2, 286 + 4, 92, 83, 18, TFT_DARKGREY);
  tft.fillRoundRect(218, 286, 92, 83, 18, TFT_PURPLE);
  tft.drawRoundRect(218, 286, 92, 83, 18, TFT_WHITE);
  tft.drawRoundRect(221, 289, 86, 77, 15, TFT_YELLOW);
  for (int r = 0; r < 4; r++) for (int c = 0; c < 4; c++)
    tft.fillRect(239 + c * 10, 301 + r * 10, 9, 9, ((r + c) & 1) ? TFT_DARKGREY : TFT_LIGHTGREY);
  // A crown-and-board logo makes Chess recognisable without putting letters in the icon.
  tft.fillRect(249, 333, 38, 7, TFT_WHITE);
  tft.fillRoundRect(253, 326, 30, 9, 3, TFT_WHITE);
  tft.fillTriangle(253, 326, 257, 307, 264, 323, TFT_WHITE);
  tft.fillTriangle(263, 326, 268, 303, 273, 323, TFT_WHITE);
  tft.fillTriangle(273, 326, 280, 307, 283, 326, TFT_WHITE);
  tft.fillCircle(257, 306, 3, TFT_YELLOW);
  tft.fillCircle(268, 302, 3, TFT_YELLOW);
  tft.fillCircle(280, 306, 3, TFT_YELLOW);
  label("Chess", 264, 354, 1, TFT_WHITE, TFT_PURPLE);
  label("Use the top arrow for more apps", 160, 415, 2, TFT_LIGHTGREY, TFT_BLACK);
}

String storageSizeText(uint64_t bytes) {
  if (bytes >= 1024ULL * 1024ULL * 1024ULL) return String((float)bytes / (1024.0f * 1024.0f * 1024.0f), 1) + " GB";
  if (bytes >= 1024ULL * 1024ULL) return String((float)bytes / (1024.0f * 1024.0f), 1) + " MB";
  if (bytes >= 1024ULL) return String((float)bytes / 1024.0f, 1) + " KB";
  return String((unsigned long)bytes) + " B";
}

void drawStorageApp() {
  page = STORAGE_PAGE;
  tft.fillScreen(TFT_BLACK);
  tft.fillRect(0, 0, SCREEN_W, 63, TFT_NAVY);
  button(8, 10, 76, 42, "< HOME", TFT_BLUE, 1);
  label("SD CARD", 207, 26, 4, TFT_WHITE, TFT_NAVY);
  if (!sdCardReady) {
    label("No card was found", 160, 190, 4, TFT_RED, TFT_BLACK);
    label("Push the microSD card in, then restart", 160, 228, 2, TFT_LIGHTGREY, TFT_BLACK);
    return;
  }
  uint64_t total = SD.totalBytes();
  uint64_t used = SD.usedBytes();
  uint64_t artBytes = 0;
  int artCount = 0;
  File root = SD.open("/");
  if (root) {
    File entry = root.openNextFile();
    while (entry) {
      String filename = String(entry.name());
      if (!entry.isDirectory() && filename.startsWith("/pixel_art_")) {
        artBytes += entry.size();
        artCount++;
      }
      entry.close();
      entry = root.openNextFile();
    }
    root.close();
  }
  uint64_t otherBytes = used > artBytes ? used - artBytes : 0;
  tft.fillRoundRect(15, 80, 290, 94, 13, TFT_DARKGREY);
  tft.drawRoundRect(15, 80, 290, 94, 13, TFT_CYAN);
  label("CARD SPACE", 160, 100, 2, TFT_CYAN, TFT_DARKGREY);
  label(storageSizeText(used) + " used of " + storageSizeText(total), 160, 127, 2, TFT_WHITE, TFT_DARKGREY);
  uint16_t fillW = total ? (uint16_t)min((uint64_t)260, used * 260ULL / total) : 0;
  tft.fillRoundRect(30, 148, 260, 12, 6, TFT_BLACK);
  if (fillW) tft.fillRoundRect(30, 148, fillW, 12, 6, TFT_GREEN);
  label(storageSizeText(total - used) + " free", 160, 190, 2, TFT_YELLOW, TFT_BLACK);
  label("WHAT USES THE MOST SPACE", 160, 230, 2, TFT_CYAN, TFT_BLACK);
  tft.fillRoundRect(15, 247, 290, 67, 12, TFT_PURPLE);
  label("Pixel Studio drawings", 160, 267, 2, TFT_WHITE, TFT_PURPLE);
  label(String(artCount) + " saved picture" + (artCount == 1 ? "" : "s") + "  -  " + storageSizeText(artBytes), 160, 293, 2, TFT_YELLOW, TFT_PURPLE);
  tft.fillRoundRect(15, 325, 290, 67, 12, TFT_DARKGREY);
  label("Other files on this card", 160, 345, 2, TFT_WHITE, TFT_DARKGREY);
  label(storageSizeText(otherBytes), 160, 371, 2, TFT_YELLOW, TFT_DARKGREY);
  label("Games and the app itself are stored in the board", 160, 424, 1, TFT_LIGHTGREY, TFT_BLACK);
  label("Their code does not use microSD-card space", 160, 445, 1, TFT_LIGHTGREY, TFT_BLACK);
}

void tapStorageApp(int x, int y) {
  if (inside(8, 10, 76, 42, x, y)) drawHome();
}

void drawSettingsApp() {
  page = SETTINGS_PAGE;
  tft.fillScreen(TFT_BLACK);
  tft.fillRect(0, 0, SCREEN_W, 63, TFT_NAVY);
  button(8, 10, 76, 42, "< HOME", TFT_BLUE, 1);
  label("SETTINGS", 205, 26, 4, TFT_WHITE, TFT_NAVY);
  tft.fillRoundRect(16, 78, 288, 125, 12, TFT_DARKGREY);
  tft.drawRoundRect(16, 78, 288, 125, 12, TFT_CYAN);
  label("SCREEN BRIGHTNESS", 160, 99, 2, TFT_CYAN, TFT_DARKGREY);
  label("Drag or tap the bar", 160, 122, 1, TFT_WHITE, TFT_DARKGREY);
  tft.fillRoundRect(34, 145, 252, 18, 9, TFT_BLACK);
  int brightnessX = 36 + (screenBrightness * 248) / 255;
  tft.fillRoundRect(36, 147, max(6, brightnessX - 34), 14, 7, TFT_YELLOW);
  tft.fillCircle(brightnessX, 154, 12, TFT_WHITE);
  tft.fillCircle(brightnessX, 154, 7, TFT_ORANGE);
  label(String((screenBrightness * 100) / 255) + "%", 160, 182, 2, TFT_YELLOW, TFT_DARKGREY);
  button(48, 248, 224, 58, "RESET GAME SCORES", TFT_MAROON, 2);
  label("This resets Snake, Flappy Bird, and Pixel Pop best scores", 160, 330, 1, TFT_LIGHTGREY, TFT_BLACK);
  label("Your saved Pixel Studio pictures are kept", 160, 353, 1, TFT_LIGHTGREY, TFT_BLACK);
}

void tapSettingsApp(int x, int y) {
  if (inside(8, 10, 76, 42, x, y)) { drawHome(); return; }
  if (inside(28, 135, 264, 40, x, y)) {
    screenBrightness = constrain(map(x, 36, 284, 0, 255), 20, 255);
    ledcWrite(TFT_BACKLIGHT_PIN, screenBrightness);
    prefs.putUChar("brightness", screenBrightness);
    drawSettingsApp();
    return;
  }
  if (inside(48, 248, 224, 58, x, y)) {
    prefs.putInt("snakebest", 0); prefs.putInt("flappybest", 0); prefs.putInt("popbest", 0);
    tft.fillRoundRect(48, 248, 224, 58, 10, TFT_DARKGREEN);
    label("SCORES RESET", 160, 277, 2, TFT_WHITE, TFT_DARKGREEN);
  }
}

void scanWifiNetworks() {
  WiFi.mode(WIFI_STA);
  WiFi.disconnect(false, false);
  int found = WiFi.scanNetworks(false, true);
  int strongest = -1;
  int strongestRssi = -1000;
  for (int i = 0; i < found; i++) {
    if (WiFi.SSID(i).length() && WiFi.RSSI(i) > strongestRssi) {
      strongest = i;
      strongestRssi = WiFi.RSSI(i);
    }
  }
  if (strongest >= 0) {
    wifiSsid = WiFi.SSID(strongest);
    wifiMessage = "Found " + wifiSsid + " - enter password";
  } else if (wifiSsid.length()) {
    wifiMessage = "No nearby network found - showing saved name";
  } else {
    wifiMessage = "No Wi-Fi name found - move closer and try again";
  }
  WiFi.scanDelete();
}

void drawWifiFields() {
  String shownSsid = wifiSsid.length() ? wifiSsid : "No network found";
  String stars = "";
  for (unsigned int i = 0; i < wifiPassword.length(); i++) stars += "*";
  String shownPassword = wifiPassword.length() ? stars : "Enter password below";
  tft.fillRoundRect(14, 72, 292, 43, 8, TFT_DARKGREEN);
  tft.drawRoundRect(14, 72, 292, 43, 8, TFT_CYAN);
  label(shownSsid, 160, 93, 2, TFT_WHITE, TFT_DARKGREEN);
  tft.fillRoundRect(14, 122, 292, 43, 8, TFT_DARKGREEN);
  tft.drawRoundRect(14, 122, 292, 43, 8, TFT_CYAN);
  label(shownPassword, 160, 143, 2, TFT_WHITE, TFT_DARKGREEN);
}

void drawWifiKey(int keyIndex, bool selected) {
  const char *letters = "QWERTYUIOPASDFGHJKLZXCVBNM";
  int x, y, w = 29, h = 38;
  String text;
  if (keyIndex < 10) { x = 3 + keyIndex * 32; y = 190; text = String(letters[keyIndex]); }
  else if (keyIndex < 19) { x = 16 + (keyIndex - 10) * 32; y = 233; text = String(letters[keyIndex]); }
  else if (keyIndex < 26) { x = 48 + (keyIndex - 19) * 32; y = 276; text = String(letters[keyIndex]); }
  else if (keyIndex < 36) { x = 3 + (keyIndex - 26) * 32; y = 319; text = String(keyIndex - 26); }
  else if (keyIndex == 36) { x = 3; y = 364; w = 50; h = 40; text = wifiShift ? "SHIFT" : "shift"; }
  else if (keyIndex == 37) { x = 58; y = 364; w = 90; h = 40; text = "SPACE"; }
  else { x = 153; y = 364; w = 53; h = 40; text = "BACK"; }
  button(x, y, w, h, text, keyIndex == 36 && wifiShift ? TFT_ORANGE : TFT_PURPLE, keyIndex < 36 ? 2 : 1);
  if (selected) tft.drawRoundRect(x + 2, y + 2, w - 4, h - 4, 7, TFT_YELLOW);
}

void drawWifiApp() {
  page = WIFI_PAGE;
  wifiEditingField = 1;
  lastWifiKey = -1;
  wifiShift = false;
  wifiKeyHeld = false;
  wifiConnecting = false;
  scanWifiNetworks();
  tft.fillScreen(TFT_BLACK);
  tft.fillRect(0, 0, SCREEN_W, 63, TFT_NAVY);
  button(8, 10, 76, 42, "< BACK", TFT_BLUE, 1);
  label("WI-FI", 206, 26, 4, TFT_WHITE, TFT_NAVY);
  drawWifiFields();
  label("FOUND NETWORK", 66, 174, 1, TFT_CYAN, TFT_BLACK);
  label("PASSWORD", 244, 174, 1, TFT_CYAN, TFT_BLACK);
  for (int i = 0; i < 39; i++) drawWifiKey(i, false);
  button(211, 364, 106, 40, "CONNECT", TFT_DARKGREEN, 1);
  label(wifiMessage, 160, 434, 1, TFT_LIGHTGREY, TFT_BLACK);
  label("Shift changes letter case; password stays hidden", 160, 461, 1, TFT_LIGHTGREY, TFT_BLACK);
}

void tapWifiApp(int x, int y) {
  if (inside(8, 10, 76, 42, x, y)) { WiFi.disconnect(); drawSettingsApp(); return; }
  if (inside(14, 72, 292, 43, x, y)) { scanWifiNetworks(); drawWifiApp(); return; }
  if (inside(14, 122, 292, 43, x, y)) { return; }
  if (inside(211, 364, 106, 40, x, y)) {
    if (!wifiSsid.length()) { wifiMessage = "Enter your network name first"; drawWifiApp(); return; }
    WiFi.mode(WIFI_STA); WiFi.begin(wifiSsid.c_str(), wifiPassword.c_str());
    wifiConnecting = true; wifiConnectStartedAt = millis(); wifiMessage = "Connecting...";
    tft.fillRect(10, 415, 300, 32, TFT_BLACK); label(wifiMessage, 160, 434, 2, TFT_YELLOW, TFT_BLACK);
    return;
  }
  int tapped = -1;
  for (int i = 0; i < 39; i++) {
    int keyX, keyY, keyW = 29, keyH = 38;
    if (i < 10) { keyX = 3 + i * 32; keyY = 190; }
    else if (i < 19) { keyX = 16 + (i - 10) * 32; keyY = 233; }
    else if (i < 26) { keyX = 48 + (i - 19) * 32; keyY = 276; }
    else if (i < 36) { keyX = 3 + (i - 26) * 32; keyY = 319; }
    else if (i == 36) { keyX = 3; keyY = 364; keyW = 50; keyH = 40; }
    else if (i == 37) { keyX = 58; keyY = 364; keyW = 90; keyH = 40; }
    else { keyX = 153; keyY = 364; keyW = 53; keyH = 40; }
    if (inside(keyX, keyY, keyW, keyH, x, y)) { tapped = i; break; }
  }
  if (tapped < 0 || wifiKeyHeld) return;
  // Lock before writing: a finger resting on a key is still only one key press.
  wifiKeyHeld = true;
  String &field = wifiPassword;
  if (tapped == 36) { wifiShift = !wifiShift; }
  else if (tapped == 37) { if (field.length() < 32) field += " "; }
  else if (tapped == 38) { if (field.length()) field.remove(field.length() - 1); }
  else if (field.length() < 32) {
    const char *letters = "QWERTYUIOPASDFGHJKLZXCVBNM";
    char next = tapped < 26 ? letters[tapped] : char('0' + tapped - 26);
    if (tapped < 26 && !wifiShift) next = char(next - 'A' + 'a');
    field += next;
  }
  if (lastWifiKey >= 0 && lastWifiKey != tapped) drawWifiKey(lastWifiKey, false);
  drawWifiKey(tapped, true); lastWifiKey = tapped;
  drawWifiFields();
}

void updateWifiConnection() {
  if (!wifiConnecting) return;
  if (WiFi.status() == WL_CONNECTED) {
    wifiConnecting = false;
    prefs.putString("wifissid", wifiSsid); prefs.putString("wifipass", wifiPassword);
    wifiMessage = "Connected to " + wifiSsid;
  } else if (millis() - wifiConnectStartedAt > 15000) {
    wifiConnecting = false; WiFi.disconnect(); wifiMessage = "Could not connect - check details";
  } else return;
  if (page == WIFI_PAGE) { tft.fillRect(10, 415, 300, 32, TFT_BLACK); label(wifiMessage, 160, 434, 2, wifiMessage.startsWith("Connected") ? TFT_GREEN : TFT_RED, TFT_BLACK); }
}

void drawHomeSecondPage() {
  page = HOME_PAGE;
  tft.fillScreen(TFT_BLACK);
  tft.fillRect(0, 0, SCREEN_W, 62, TFT_NAVY);
  button(8, 9, 44, 42, "<", TFT_BLUE, 4);
  button(268, 9, 44, 42, ">", TFT_BLUE, 4);
  label("MORE APPS", 160, 23, 4, TFT_WHITE, TFT_NAVY);
  label("Apps 2 of 2", 160, 48, 2, TFT_CYAN, TFT_NAVY);

  // Storage card: a graphical microSD with a clear usage bar.
  tft.fillRoundRect(20, 92, 130, 130, 25, TFT_DARKGREY);
  tft.drawRoundRect(20, 92, 130, 130, 25, TFT_WHITE);
  tft.drawRoundRect(24, 96, 122, 122, 21, TFT_YELLOW);
  tft.fillRoundRect(46, 111, 78, 76, 8, TFT_BLACK);
  tft.fillRect(55, 111, 8, 18, TFT_GOLD); tft.fillRect(67, 111, 8, 18, TFT_GOLD); tft.fillRect(79, 111, 8, 18, TFT_GOLD); tft.fillRect(91, 111, 8, 18, TFT_GOLD);
  tft.fillRoundRect(55, 151, 60, 13, 5, TFT_DARKGREY); tft.fillRoundRect(55, 151, 34, 13, 5, TFT_GREEN);
  label("Storage", 85, 204, 2, TFT_WHITE, TFT_DARKGREY);

  // Settings card: a graphical gear, not a text-letter icon.
  tft.fillRoundRect(170, 92, 130, 130, 25, TFT_PURPLE);
  tft.drawRoundRect(170, 92, 130, 130, 25, TFT_WHITE);
  tft.drawRoundRect(174, 96, 122, 122, 21, TFT_YELLOW);
  int cx = 235, cy = 149;
  for (int i = 0; i < 8; i++) { float a = i * 0.785f; tft.fillCircle(cx + (int)(cosf(a) * 26), cy + (int)(sinf(a) * 26), 8, TFT_LIGHTGREY); }
  tft.fillCircle(cx, cy, 25, TFT_LIGHTGREY); tft.fillCircle(cx, cy, 12, TFT_PURPLE); tft.fillCircle(cx, cy, 6, TFT_DARKGREY);
  label("Settings", 235, 204, 2, TFT_WHITE, TFT_PURPLE);
  label("Use the arrows at the top to change pages", 160, 270, 2, TFT_LIGHTGREY, TFT_BLACK);
}

void drawResistorValue() {
  long significant = resistorBand[0] * 10L + resistorBand[1];
  int multiplierBand;
  int toleranceBand;
  if (resistorBandCount == 5) {
    significant = significant * 10L + resistorBand[2];
    multiplierBand = 3;
    toleranceBand = 4;
  } else {
    multiplierBand = 2;
    toleranceBand = 3;
  }
  long ohms = significant;
  for (int i = 0; i < resistorBand[multiplierBand]; i++) ohms *= 10L;
  String value;
  if (ohms >= 1000000L) value = String(ohms / 1000000.0f, ohms % 1000000L ? 2 : 0) + " M ohms";
  else if (ohms >= 1000L) value = String(ohms / 1000.0f, ohms % 1000L ? 2 : 0) + " k ohms";
  else value = String(ohms) + " ohms";
  const char *tolerance[10] = { "±20%", "±1%", "±2%", "±3%", "±4%", "±0.5%", "±0.25%", "±0.1%", "±0.05%", "±0.05%" };
  tft.fillRoundRect(18, 251, 284, 70, 12, TFT_DARKGREY);
  tft.drawRoundRect(18, 251, 284, 70, 12, TFT_CYAN);
  label(value, 160, 275, 4, TFT_WHITE, TFT_DARKGREY);
  label(String("Tolerance: ") + tolerance[resistorBand[toleranceBand]], 160, 302, 2, TFT_YELLOW, TFT_DARKGREY);
}

void drawResistorCalculator() {
  page = RESISTOR_PAGE;
  tft.fillScreen(TFT_BLACK);
  tft.fillRect(0, 0, SCREEN_W, 63, TFT_NAVY);
  button(8, 10, 76, 42, "< HOME", TFT_BLUE, 1);
  label("RESISTOR", 205, 25, 4, TFT_WHITE, TFT_NAVY);
  button(42, 70, 112, 35, "4 BAND", resistorBandCount == 4 ? TFT_DARKGREEN : TFT_DARKGREY, 2);
  button(166, 70, 112, 35, "5 BAND", resistorBandCount == 5 ? TFT_DARKGREEN : TFT_DARKGREY, 2);
  label("Tap a band, then choose its colour", 160, 115, 1, TFT_CYAN, TFT_BLACK);
  // The resistor body changes from four to five well-spaced editable bands.
  tft.fillRoundRect(25, 126, 270, 101, 24, tft.color565(225, 195, 145));
  int bandWidth = resistorBandCount == 4 ? 35 : 29;
  int bandStep = resistorBandCount == 4 ? 59 : 48;
  int firstBandX = resistorBandCount == 4 ? 53 : 49;
  for (int b = 0; b < resistorBandCount; b++) {
    int bandX = firstBandX + b * bandStep;
    tft.fillRoundRect(bandX, 132, bandWidth, 89, 5, resistorColours[resistorBand[b]]);
    tft.drawRoundRect(bandX, 132, bandWidth, 89, 5, b == resistorEditingBand ? TFT_CYAN : TFT_BLACK);
    label(String(b + 1), bandX + bandWidth / 2, 238, 1, b == resistorEditingBand ? TFT_CYAN : TFT_LIGHTGREY, TFT_BLACK);
  }
  drawResistorValue();
  label("COLOUR PALETTE", 160, 338, 2, TFT_LIGHTGREY, TFT_BLACK);
  for (int i = 0; i < 10; i++) {
    int col = i % 5, row = i / 5;
    int colourX = 8 + col * 63, colourY = 354 + row * 51;
    button(colourX, colourY, 56, 43, resistorColourNames[i], resistorColours[i], 1);
    if (i == resistorBand[resistorEditingBand]) tft.drawRoundRect(colourX + 2, colourY + 2, 52, 39, 8, TFT_CYAN);
  }
}

void tapResistorCalculator(int x, int y) {
  if (inside(8, 10, 76, 42, x, y)) { drawHome(); return; }
  if (inside(42, 70, 112, 35, x, y)) { resistorBandCount = 4; resistorEditingBand = min(resistorEditingBand, 3); drawResistorCalculator(); return; }
  if (inside(166, 70, 112, 35, x, y)) { resistorBandCount = 5; resistorEditingBand = min(resistorEditingBand, 4); drawResistorCalculator(); return; }
  int bandWidth = resistorBandCount == 4 ? 35 : 29;
  int bandStep = resistorBandCount == 4 ? 59 : 48;
  int firstBandX = resistorBandCount == 4 ? 53 : 49;
  for (int b = 0; b < resistorBandCount; b++) {
    if (inside(firstBandX + b * bandStep, 132, bandWidth, 89, x, y)) { resistorEditingBand = b; drawResistorCalculator(); return; }
  }
  for (int i = 0; i < 10; i++) {
    int col = i % 5, row = i / 5;
    if (inside(8 + col * 63, 354 + row * 51, 56, 43, x, y)) {
      resistorBand[resistorEditingBand] = i;
      drawResistorCalculator();
      return;
    }
  }
}

void drawCalculatorDisplay() {
  tft.fillRoundRect(14, 78, 292, 82, 12, TFT_DARKGREY);
  tft.drawRoundRect(14, 78, 292, 82, 12, TFT_CYAN);
  String shown = calculatorEntry;
  if (shown.length() > 15) shown = shown.substring(shown.length() - 15);
  tft.setTextDatum(TR_DATUM);
  tft.setTextColor(TFT_WHITE, TFT_DARKGREY);
  tft.drawString(shown, 294, 120, 6);
  tft.setTextDatum(MC_DATUM);
  String operation = calculatorOperation ? String(calculatorOperation) : "";
  label(operation, 31, 94, 2, TFT_YELLOW, TFT_DARKGREY);
}

void drawCalculator() {
  page = CALCULATOR_PAGE;
  calculatorEntry = "0";
  calculatorValue = 0.0f;
  calculatorOperation = 0;
  calculatorStartNewNumber = true;
  tft.fillScreen(TFT_BLACK);
  tft.fillRect(0, 0, SCREEN_W, 62, TFT_NAVY);
  button(8, 10, 75, 42, "< HOME", TFT_BLUE, 1);
  label("CALCULATOR", 205, 26, 4, TFT_WHITE, TFT_NAVY);
  drawCalculatorDisplay();
  // Four tidy rows plus two wide bottom buttons leave room for divide and 1/x.
  const char *keys[16] = { "7", "8", "9", "/", "4", "5", "6", "x", "1", "2", "3", "-", "C", "0", ".", "+" };
  uint16_t colours[16] = { TFT_DARKGREY, TFT_DARKGREY, TFT_DARKGREY, TFT_ORANGE, TFT_DARKGREY, TFT_DARKGREY, TFT_DARKGREY, TFT_ORANGE, TFT_DARKGREY, TFT_DARKGREY, TFT_DARKGREY, TFT_ORANGE, TFT_RED, TFT_DARKGREY, TFT_DARKGREY, TFT_ORANGE };
  for (int i = 0; i < 16; i++) {
    int col = i % 4, row = i / 4;
    button(16 + col * 75, 170 + row * 60, 62, 48, keys[i], colours[i], 4);
  }
  button(16, 414, 137, 54, "1/x", TFT_PURPLE, 3);
  button(167, 414, 137, 54, "=", TFT_DARKGREEN, 4);
}

void tapCalculator(int x, int y) {
  if (inside(8, 10, 75, 42, x, y)) { drawHome(); return; }
  char key = 0;
  if (inside(16, 170, 287, 228, x, y)) {
    int col = (x - 16) / 75;
    int row = (y - 170) / 60;
    if (col < 0 || col > 3 || row < 0 || row > 3) return;
    const char keys[16] = { '7', '8', '9', '/', '4', '5', '6', 'x', '1', '2', '3', '-', 'C', '0', '.', '+' };
    key = keys[row * 4 + col];
  } else if (inside(16, 414, 137, 54, x, y)) {
    key = 'R'; // Reciprocal: 1 divided by the number on the display.
  } else if (inside(167, 414, 137, 54, x, y)) {
    key = '=';
  } else return;
  // Lock before changing the display: one physical touch can enter only one key.
  if (calculatorKeyHeld) return;
  calculatorKeyHeld = true;
  if (key >= '0' && key <= '9') {
    if (calculatorStartNewNumber || calculatorEntry == "0") calculatorEntry = String(key);
    else if (calculatorEntry.length() < 15) calculatorEntry += key;
    calculatorStartNewNumber = false;
  } else if (key == '.') {
    if (calculatorStartNewNumber) { calculatorEntry = "0."; calculatorStartNewNumber = false; }
    else if (calculatorEntry.indexOf('.') < 0) calculatorEntry += '.';
  } else if (key == 'C') {
    calculatorEntry = "0"; calculatorValue = 0; calculatorOperation = 0; calculatorStartNewNumber = true;
  } else if (key == 'R') {
    float entered = calculatorEntry.toFloat();
    if (entered == 0.0f) calculatorEntry = "ERROR";
    else {
      calculatorEntry = String(1.0f / entered, 6);
      while (calculatorEntry.endsWith("0")) calculatorEntry.remove(calculatorEntry.length() - 1);
      if (calculatorEntry.endsWith(".")) calculatorEntry.remove(calculatorEntry.length() - 1);
    }
    calculatorOperation = 0;
    calculatorStartNewNumber = true;
  } else {
    float entered = calculatorEntry.toFloat();
    if (calculatorOperation) {
      if (calculatorOperation == '+') calculatorValue += entered;
      else if (calculatorOperation == '-') calculatorValue -= entered;
      else if (calculatorOperation == 'x') calculatorValue *= entered;
      else if (calculatorOperation == '/') {
        if (entered == 0) { calculatorEntry = "ERROR"; calculatorOperation = 0; calculatorStartNewNumber = true; drawCalculatorDisplay(); return; }
        calculatorValue /= entered;
      }
    } else calculatorValue = entered;
    if (key == '=') {
      calculatorEntry = String(calculatorValue, 4);
      while (calculatorEntry.endsWith("0")) calculatorEntry.remove(calculatorEntry.length() - 1);
      if (calculatorEntry.endsWith(".")) calculatorEntry.remove(calculatorEntry.length() - 1);
      calculatorOperation = 0;
    } else {
      calculatorOperation = key;
    }
    calculatorStartNewNumber = true;
  }
  drawCalculatorDisplay();
}

bool chessWhite(char piece) { return piece >= 'A' && piece <= 'Z'; }

void drawChessPiece(int col, int row) {
  char piece = chessBoard[row][col];
  int x = CHESS_X + col * CHESS_CELL;
  int y = CHESS_Y + row * CHESS_CELL;
  uint16_t square = ((row + col) & 1) ? tft.color565(105, 72, 45) : tft.color565(234, 210, 170);
  tft.fillRect(x, y, CHESS_CELL, CHESS_CELL, square);
  if (piece == ' ') return;

  // Draw recognisable piece pictures instead of letter labels. White pieces have
  // a dark rim; black pieces have a light rim so both sides stay easy to see.
  char kind = piece >= 'a' && piece <= 'z' ? piece - 'a' + 'A' : piece;
  uint16_t fill = chessWhite(piece) ? TFT_WHITE : TFT_BLACK;
  uint16_t outline = chessWhite(piece) ? TFT_DARKGREY : TFT_LIGHTGREY;
  int cx = x + 16;
  int baseY = y + 26;

  if (kind == 'P') { // pawn: round head, narrow neck, and base
    tft.fillCircle(cx, y + 10, 5, fill);
    tft.drawCircle(cx, y + 10, 5, outline);
    tft.fillRoundRect(cx - 5, y + 15, 10, 8, 3, fill);
    tft.drawRoundRect(cx - 5, y + 15, 10, 8, 3, outline);
  } else if (kind == 'R') { // rook: castle top with three towers
    tft.fillRect(cx - 9, y + 8, 18, 15, fill);
    tft.drawRect(cx - 9, y + 8, 18, 15, outline);
    tft.fillRect(cx - 10, y + 6, 4, 5, fill);
    tft.fillRect(cx - 2, y + 6, 4, 5, fill);
    tft.fillRect(cx + 6, y + 6, 4, 5, fill);
    tft.drawFastHLine(cx - 10, y + 6, 4, outline);
    tft.drawFastHLine(cx - 2, y + 6, 4, outline);
    tft.drawFastHLine(cx + 6, y + 6, 4, outline);
  } else if (kind == 'N') { // knight: horse head and neck
    tft.fillTriangle(cx - 7, y + 23, cx - 5, y + 8, cx + 7, y + 10, fill);
    tft.fillTriangle(cx - 5, y + 8, cx + 4, y + 5, cx + 8, y + 18, fill);
    tft.drawLine(cx - 7, y + 23, cx - 5, y + 8, outline);
    tft.drawLine(cx - 5, y + 8, cx + 4, y + 5, outline);
    tft.drawLine(cx + 4, y + 5, cx + 8, y + 18, outline);
    tft.fillCircle(cx + 2, y + 11, 1, outline);
  } else if (kind == 'B') { // bishop: pointed top, body, and base
    tft.fillTriangle(cx, y + 5, cx - 6, y + 15, cx + 6, y + 15, fill);
    tft.fillRoundRect(cx - 6, y + 14, 12, 9, 4, fill);
    tft.drawTriangle(cx, y + 5, cx - 6, y + 15, cx + 6, y + 15, outline);
    tft.drawRoundRect(cx - 6, y + 14, 12, 9, 4, outline);
    tft.drawLine(cx - 2, y + 9, cx + 3, y + 14, outline);
  } else if (kind == 'Q') { // queen: crown with three points
    tft.fillRoundRect(cx - 9, y + 16, 18, 8, 3, fill);
    tft.fillTriangle(cx - 9, y + 17, cx - 7, y + 7, cx - 2, y + 17, fill);
    tft.fillTriangle(cx - 3, y + 17, cx, y + 4, cx + 3, y + 17, fill);
    tft.fillTriangle(cx + 2, y + 17, cx + 7, y + 7, cx + 9, y + 17, fill);
    tft.drawRoundRect(cx - 9, y + 16, 18, 8, 3, outline);
    tft.drawCircle(cx, y + 5, 2, outline);
  } else { // king: tall crown topped with a cross
    tft.fillRoundRect(cx - 7, y + 14, 14, 10, 3, fill);
    tft.fillTriangle(cx - 7, y + 15, cx, y + 8, cx + 7, y + 15, fill);
    tft.drawRoundRect(cx - 7, y + 14, 14, 10, 3, outline);
    tft.drawFastVLine(cx, y + 3, 6, outline);
    tft.drawFastHLine(cx - 3, y + 5, 7, outline);
  }
  tft.drawFastHLine(cx - 11, baseY, 23, outline);
}

void drawChessStatus() {
  tft.fillRoundRect(8, 365, 304, 45, 10, TFT_DARKGREY);
  tft.drawRoundRect(8, 365, 304, 45, 10, TFT_CYAN);
  label(chessMessage, 160, 387, 2, TFT_WHITE, TFT_DARKGREY);
}

void chessMakeAiMove() {
  // This compact beginner AI chooses from real legal Black moves. It takes a
  // capture when available, otherwise it chooses a legal move at random.
  int fromC[128], fromR[128], toC[128], toR[128];
  bool isCapture[128];
  int count = 0;
  for (int fr = 0; fr < 8; fr++) for (int fc = 0; fc < 8; fc++) {
    if (chessBoard[fr][fc] == ' ' || chessWhite(chessBoard[fr][fc])) continue;
    for (int tr = 0; tr < 8; tr++) for (int tc = 0; tc < 8; tc++) {
      if (chessMoveIsLegal(fc, fr, tc, tr) && count < 128) {
        fromC[count] = fc; fromR[count] = fr; toC[count] = tc; toR[count] = tr;
        isCapture[count] = chessBoard[tr][tc] != ' ';
        count++;
      }
    }
  }
  if (!count) { chessMessage = "Black has no moves"; drawChessStatus(); return; }
  int choices[128], choiceCount = 0;
  for (int i = 0; i < count; i++) if (isCapture[i]) choices[choiceCount++] = i;
  int pick = choiceCount ? choices[random(choiceCount)] : random(count);
  char moving = chessBoard[fromR[pick]][fromC[pick]];
  chessBoard[toR[pick]][toC[pick]] = moving;
  chessBoard[fromR[pick]][fromC[pick]] = ' ';
  if (moving == 'p' && toR[pick] == 7) chessBoard[toR[pick]][toC[pick]] = 'q';
  drawChessPiece(fromC[pick], fromR[pick]);
  drawChessPiece(toC[pick], toR[pick]);
  chessWhiteTurn = true;
  chessMessage = "Your turn: White";
  drawChessStatus();
}

bool chessMoveIsLegal(int fc, int fr, int tc, int tr) {
  char piece = chessBoard[fr][fc];
  char target = chessBoard[tr][tc];
  if (piece == ' ' || (target != ' ' && chessWhite(piece) == chessWhite(target))) return false;
  int dx = tc - fc, dy = tr - fr, ax = abs(dx), ay = abs(dy);
  char kind = piece >= 'a' && piece <= 'z' ? piece - 'a' + 'A' : piece;
  if (kind == 'P') {
    int forward = chessWhite(piece) ? -1 : 1;
    int start = chessWhite(piece) ? 6 : 1;
    if (dx == 0 && dy == forward && target == ' ') return true;
    if (dx == 0 && dy == 2 * forward && fr == start && target == ' ' && chessBoard[fr + forward][fc] == ' ') return true;
    return ax == 1 && dy == forward && target != ' ';
  }
  if (kind == 'N') return (ax == 1 && ay == 2) || (ax == 2 && ay == 1);
  if (kind == 'K') return ax <= 1 && ay <= 1;
  if (kind == 'B' && ax != ay) return false;
  if (kind == 'R' && dx != 0 && dy != 0) return false;
  if (kind == 'Q' && !(ax == ay || dx == 0 || dy == 0)) return false;
  int sx = dx == 0 ? 0 : dx / ax, sy = dy == 0 ? 0 : dy / ay;
  for (int c = fc + sx, r = fr + sy; c != tc || r != tr; c += sx, r += sy) if (chessBoard[r][c] != ' ') return false;
  return true;
}

void drawChessGame() {
  page = CHESS_PAGE;
  tft.fillScreen(TFT_BLACK);
  tft.fillRect(0, 0, SCREEN_W, 63, TFT_NAVY);
  button(8, 10, 60, 42, "HOME", TFT_BLUE, 1);
  button(72, 10, 82, 42, chessAiMode ? "AI: ON" : "2 PLAYER", chessAiMode ? TFT_DARKGREEN : TFT_PURPLE, 1);
  button(246, 10, 66, 42, "NEW", TFT_PURPLE, 1);
  label("CHESS", 201, 27, 4, TFT_WHITE, TFT_NAVY);
  for (int r = 0; r < 8; r++) for (int c = 0; c < 8; c++) drawChessPiece(c, r);
  chessSelectedCol = chessSelectedRow = -1;
  chessMessage = chessAiMode ? "Your turn: White" : (chessWhiteTurn ? "White to move" : "Black to move");
  drawChessStatus();
  label(chessAiMode ? "You are White; board plays Black" : "Tap a piece, then tap where it moves", 160, 436, 2, TFT_CYAN, TFT_BLACK);
}

void tapChessGame(int x, int y) {
  if (inside(8, 10, 60, 42, x, y)) { drawHome(); return; }
  if (inside(72, 10, 82, 42, x, y)) { chessAiMode = !chessAiMode; chessWhiteTurn = true; chessSelectedCol = chessSelectedRow = -1; drawChessGame(); return; }
  if (inside(246, 10, 66, 42, x, y)) {
    const char fresh[8][8] = {{'r','n','b','q','k','b','n','r'},{'p','p','p','p','p','p','p','p'},{' ',' ',' ',' ',' ',' ',' ',' '},{' ',' ',' ',' ',' ',' ',' ',' '},{' ',' ',' ',' ',' ',' ',' ',' '},{' ',' ',' ',' ',' ',' ',' ',' '},{'P','P','P','P','P','P','P','P'},{'R','N','B','Q','K','B','N','R'}};
    memcpy(chessBoard, fresh, sizeof(chessBoard)); chessWhiteTurn = true; drawChessGame(); return;
  }
  if (!inside(CHESS_X, CHESS_Y, 8 * CHESS_CELL, 8 * CHESS_CELL, x, y)) return;
  int col = (x - CHESS_X) / CHESS_CELL, row = (y - CHESS_Y) / CHESS_CELL;
  char touched = chessBoard[row][col];
  if (chessSelectedCol < 0) {
    if (touched != ' ' && chessWhite(touched) == chessWhiteTurn) {
      chessSelectedCol = col; chessSelectedRow = row;
      drawChessPiece(col, row);
      tft.drawRect(CHESS_X + col * CHESS_CELL + 2, CHESS_Y + row * CHESS_CELL + 2, CHESS_CELL - 4, CHESS_CELL - 4, TFT_YELLOW);
      chessMessage = "Choose a destination"; drawChessStatus();
    }
    return;
  }
  if (chessMoveIsLegal(chessSelectedCol, chessSelectedRow, col, row)) {
    char moving = chessBoard[chessSelectedRow][chessSelectedCol];
    chessBoard[row][col] = moving; chessBoard[chessSelectedRow][chessSelectedCol] = ' ';
    if ((moving == 'P' && row == 0) || (moving == 'p' && row == 7)) chessBoard[row][col] = chessWhite(moving) ? 'Q' : 'q';
    drawChessPiece(chessSelectedCol, chessSelectedRow); drawChessPiece(col, row);
    chessWhiteTurn = !chessWhiteTurn;
    chessMessage = chessWhiteTurn ? "White to move" : "Black to move";
    if (chessAiMode && !chessWhiteTurn) {
      chessSelectedCol = chessSelectedRow = -1;
      chessMessage = "Board is thinking...";
      drawChessStatus();
      delay(180);
      chessMakeAiMove();
      return;
    }
  } else {
    drawChessPiece(chessSelectedCol, chessSelectedRow);
    chessMessage = "That piece cannot move there";
  }
  chessSelectedCol = chessSelectedRow = -1;
  drawChessStatus();
}

void drawCheckersPiece(int col, int row) {
  char piece = checkersBoard[row][col];
  int x = CHESS_X + col * CHESS_CELL;
  int y = CHESS_Y + row * CHESS_CELL;
  uint16_t square = ((row + col) & 1) ? tft.color565(104, 72, 46) : tft.color565(237, 214, 177);
  tft.fillRect(x, y, CHESS_CELL, CHESS_CELL, square);
  if (piece == ' ') return;
  bool red = piece == 'r' || piece == 'R';
  uint16_t outer = red ? TFT_RED : TFT_BLUE;
  uint16_t inner = red ? TFT_MAROON : TFT_NAVY;
  tft.fillCircle(x + 16, y + 16, 12, TFT_DARKGREY);
  tft.fillCircle(x + 16, y + 15, 11, outer);
  tft.drawCircle(x + 16, y + 15, 11, TFT_WHITE);
  tft.fillCircle(x + 16, y + 15, 7, inner);
  if (piece == 'R' || piece == 'B') label("K", x + 16, y + 16, 2, TFT_YELLOW, inner);
}

void drawCheckersStatus() {
  tft.fillRoundRect(8, 365, 304, 45, 10, TFT_DARKGREY);
  tft.drawRoundRect(8, 365, 304, 45, 10, TFT_CYAN);
  label(checkersMessage, 160, 387, 2, TFT_WHITE, TFT_DARKGREY);
}

void checkersMakeAiMove() {
  int fromC[96], fromR[96], toC[96], toR[96], picks[96];
  bool capture[96];
  int count = 0, captureCount = 0;
  for (int fr = 0; fr < 8; fr++) for (int fc = 0; fc < 8; fc++) {
    char piece = checkersBoard[fr][fc];
    if (piece != 'b' && piece != 'B') continue;
    for (int tr = 0; tr < 8; tr++) for (int tc = 0; tc < 8; tc++) {
      if (!checkersMoveIsLegal(fc, fr, tc, tr) || count >= 96) continue;
      fromC[count] = fc; fromR[count] = fr; toC[count] = tc; toR[count] = tr;
      capture[count] = abs(tc - fc) == 2;
      if (capture[count]) picks[captureCount++] = count;
      count++;
    }
  }
  if (!count) { checkersMessage = "Blue has no moves"; drawCheckersStatus(); return; }
  int choice = captureCount ? picks[random(captureCount)] : random(count);
  char moving = checkersBoard[fromR[choice]][fromC[choice]];
  checkersBoard[toR[choice]][toC[choice]] = moving;
  checkersBoard[fromR[choice]][fromC[choice]] = ' ';
  if (capture[choice]) checkersBoard[(fromR[choice] + toR[choice]) / 2][(fromC[choice] + toC[choice]) / 2] = ' ';
  if (moving == 'b' && toR[choice] == 7) checkersBoard[toR[choice]][toC[choice]] = 'B';
  drawCheckersPiece(fromC[choice], fromR[choice]);
  if (capture[choice]) drawCheckersPiece((fromC[choice] + toC[choice]) / 2, (fromR[choice] + toR[choice]) / 2);
  drawCheckersPiece(toC[choice], toR[choice]);
  checkersRedTurn = true;
  checkersMessage = "Your turn: Red";
  drawCheckersStatus();
}

bool checkersMoveIsLegal(int fc, int fr, int tc, int tr) {
  char piece = checkersBoard[fr][fc];
  char target = checkersBoard[tr][tc];
  if (piece == ' ' || target != ' ') return false;
  bool red = piece == 'r' || piece == 'R';
  int dx = tc - fc, dy = tr - fr;
  int ax = abs(dx), ay = abs(dy);
  bool king = piece == 'R' || piece == 'B';
  if (ax != ay || (ax != 1 && ax != 2)) return false;
  if (!king && (red ? dy >= 0 : dy <= 0)) return false;
  if (ax == 1) return true;
  int jumpedCol = (fc + tc) / 2, jumpedRow = (fr + tr) / 2;
  char jumped = checkersBoard[jumpedRow][jumpedCol];
  return jumped != ' ' && ((jumped == 'r' || jumped == 'R') != red);
}

void drawCheckersGame() {
  page = CHECKERS_PAGE;
  tft.fillScreen(TFT_BLACK);
  tft.fillRect(0, 0, SCREEN_W, 63, TFT_NAVY);
  button(8, 10, 60, 42, "HOME", TFT_BLUE, 1);
  button(72, 10, 82, 42, checkersAiMode ? "AI: ON" : "2 PLAYER", checkersAiMode ? TFT_DARKGREEN : TFT_PURPLE, 1);
  button(246, 10, 66, 42, "NEW", TFT_PURPLE, 1);
  label("CHECKERS", 201, 27, 4, TFT_WHITE, TFT_NAVY);
  for (int r = 0; r < 8; r++) for (int c = 0; c < 8; c++) drawCheckersPiece(c, r);
  checkersSelectedCol = checkersSelectedRow = -1;
  checkersMessage = checkersAiMode ? "Your turn: Red" : (checkersRedTurn ? "Red to move" : "Blue to move");
  drawCheckersStatus();
  label(checkersAiMode ? "You are Red; board plays Blue" : "Tap a piece, then a dark square", 160, 436, 2, TFT_CYAN, TFT_BLACK);
}

void tapCheckersGame(int x, int y) {
  if (inside(8, 10, 60, 42, x, y)) { drawHome(); return; }
  if (inside(72, 10, 82, 42, x, y)) { checkersAiMode = !checkersAiMode; checkersRedTurn = true; checkersSelectedCol = checkersSelectedRow = -1; drawCheckersGame(); return; }
  if (inside(246, 10, 66, 42, x, y)) {
    const char fresh[8][8] = {
      {' ','b',' ','b',' ','b',' ','b'}, {'b',' ','b',' ','b',' ','b',' '},
      {' ','b',' ','b',' ','b',' ','b'}, {' ',' ',' ',' ',' ',' ',' ',' '},
      {' ',' ',' ',' ',' ',' ',' ',' '}, {'r',' ','r',' ','r',' ','r',' '},
      {' ','r',' ','r',' ','r',' ','r'}, {'r',' ','r',' ','r',' ','r',' '}
    };
    memcpy(checkersBoard, fresh, sizeof(checkersBoard));
    checkersRedTurn = true;
    drawCheckersGame();
    return;
  }
  if (!inside(CHESS_X, CHESS_Y, 8 * CHESS_CELL, 8 * CHESS_CELL, x, y)) return;
  int col = (x - CHESS_X) / CHESS_CELL, row = (y - CHESS_Y) / CHESS_CELL;
  char touched = checkersBoard[row][col];
  bool touchedRed = touched == 'r' || touched == 'R';
  if (checkersSelectedCol < 0) {
    if (touched != ' ' && touchedRed == checkersRedTurn) {
      checkersSelectedCol = col; checkersSelectedRow = row;
      drawCheckersPiece(col, row);
      tft.drawRect(CHESS_X + col * CHESS_CELL + 2, CHESS_Y + row * CHESS_CELL + 2, CHESS_CELL - 4, CHESS_CELL - 4, TFT_YELLOW);
      checkersMessage = "Choose a dark square";
      drawCheckersStatus();
    }
    return;
  }
  if (checkersMoveIsLegal(checkersSelectedCol, checkersSelectedRow, col, row)) {
    char moving = checkersBoard[checkersSelectedRow][checkersSelectedCol];
    bool captured = abs(col - checkersSelectedCol) == 2;
    int oldCol = checkersSelectedCol, oldRow = checkersSelectedRow;
    checkersBoard[row][col] = moving;
    checkersBoard[oldRow][oldCol] = ' ';
    if (captured) checkersBoard[(oldRow + row) / 2][(oldCol + col) / 2] = ' ';
    if (moving == 'r' && row == 0) checkersBoard[row][col] = 'R';
    if (moving == 'b' && row == 7) checkersBoard[row][col] = 'B';
    drawCheckersPiece(oldCol, oldRow);
    if (captured) drawCheckersPiece((oldCol + col) / 2, (oldRow + row) / 2);
    drawCheckersPiece(col, row);
    checkersRedTurn = !checkersRedTurn;
    checkersMessage = checkersRedTurn ? "Red to move" : "Blue to move";
    if (checkersAiMode && !checkersRedTurn) {
      checkersSelectedCol = checkersSelectedRow = -1;
      checkersMessage = "Board is thinking...";
      drawCheckersStatus();
      delay(180);
      checkersMakeAiMove();
      return;
    }
  } else {
    drawCheckersPiece(checkersSelectedCol, checkersSelectedRow);
    checkersMessage = "That move is not allowed";
  }
  checkersSelectedCol = checkersSelectedRow = -1;
  drawCheckersStatus();
}

void drawSudokuCell(int col, int row) {
  int x = SUDOKU_X + col * SUDOKU_CELL;
  int y = SUDOKU_Y + row * SUDOKU_CELL;
  bool given = sudokuStart[row][col] != 0;
  uint16_t fill = given ? tft.color565(205, 225, 245) : TFT_WHITE;
  tft.fillRect(x + 1, y + 1, SUDOKU_CELL - 2, SUDOKU_CELL - 2, fill);
  uint8_t value = sudokuBoard[row][col];
  // Font 4 gives every digit a large, clear shape that fills its square.
  if (value) label(String(value), x + SUDOKU_CELL / 2, y + SUDOKU_CELL / 2, 4, given ? TFT_NAVY : TFT_DARKGREEN, fill);
  if (col == sudokuSelectedCol && row == sudokuSelectedRow) tft.drawRect(x + 3, y + 3, SUDOKU_CELL - 6, SUDOKU_CELL - 6, TFT_ORANGE);
}

void drawSudokuStatus() {
  tft.fillRoundRect(12, 356, 296, 32, 9, TFT_DARKGREY);
  tft.drawRoundRect(12, 356, 296, 32, 9, TFT_CYAN);
  label(sudokuMessage, 160, 372, 2, TFT_WHITE, TFT_DARKGREY);
}

void sudokuNewPuzzle() {
  uint8_t digits[9] = {1,2,3,4,5,6,7,8,9};
  int rowOrder[9], colOrder[9];
  for (int i = 8; i > 0; i--) { int j = random(i + 1); uint8_t d = digits[i]; digits[i] = digits[j]; digits[j] = d; }
  int bands[3] = {0,1,2}, stacks[3] = {0,1,2};
  for (int i = 2; i > 0; i--) { int j = random(i + 1); int t = bands[i]; bands[i] = bands[j]; bands[j] = t; j = random(i + 1); t = stacks[i]; stacks[i] = stacks[j]; stacks[j] = t; }
  for (int group = 0; group < 3; group++) {
    int rows[3] = {0,1,2}, cols[3] = {0,1,2};
    for (int i = 2; i > 0; i--) { int j = random(i + 1); int t = rows[i]; rows[i] = rows[j]; rows[j] = t; j = random(i + 1); t = cols[i]; cols[i] = cols[j]; cols[j] = t; }
    for (int i = 0; i < 3; i++) { rowOrder[group * 3 + i] = bands[group] * 3 + rows[i]; colOrder[group * 3 + i] = stacks[group] * 3 + cols[i]; }
  }
  for (int r = 0; r < 9; r++) for (int c = 0; c < 9; c++) sudokuSolution[r][c] = digits[sudokuBase[rowOrder[r]][colOrder[c]] - 1];
  memcpy(sudokuStart, sudokuSolution, sizeof(sudokuStart));
  int clues = sudokuDifficulty == 0 ? 45 : (sudokuDifficulty == 1 ? 36 : 29);
  for (int removed = 0; removed < 81 - clues;) { int cell = random(81); int r = cell / 9, c = cell % 9; if (sudokuStart[r][c]) { sudokuStart[r][c] = 0; removed++; } }
}

void drawSudokuGame() {
  page = SUDOKU_PAGE;
  sudokuNewPuzzle();
  memcpy(sudokuBoard, sudokuStart, sizeof(sudokuBoard));
  sudokuSelectedCol = sudokuSelectedRow = -1;
  sudokuMessage = "Tap an empty square";
  tft.fillScreen(TFT_BLACK);
  tft.fillRect(0, 0, SCREEN_W, 63, TFT_NAVY);
  button(8, 10, 60, 42, "HOME", TFT_BLUE, 1);
  const char *difficultyNames[3] = { "EASY", "MED", "HARD" };
  button(76, 10, 82, 42, difficultyNames[sudokuDifficulty], sudokuDifficulty == 0 ? TFT_DARKGREEN : (sudokuDifficulty == 1 ? TFT_ORANGE : TFT_MAROON), 1);
  button(246, 10, 66, 42, "NEW", TFT_PURPLE, 1);
  label("SUDOKU", 197, 27, 4, TFT_WHITE, TFT_NAVY);
  tft.fillRect(SUDOKU_X - 2, SUDOKU_Y - 2, 9 * SUDOKU_CELL + 4, 9 * SUDOKU_CELL + 4, TFT_NAVY);
  for (int r = 0; r < 9; r++) for (int c = 0; c < 9; c++) drawSudokuCell(c, r);
  for (int i = 0; i <= 9; i++) {
    uint16_t line = i % 3 == 0 ? TFT_NAVY : TFT_DARKGREY;
    tft.drawFastVLine(SUDOKU_X + i * SUDOKU_CELL, SUDOKU_Y, 9 * SUDOKU_CELL, line);
    tft.drawFastHLine(SUDOKU_X, SUDOKU_Y + i * SUDOKU_CELL, 9 * SUDOKU_CELL, line);
  }
  drawSudokuStatus();
  for (int i = 0; i < 9; i++) {
    int col = i % 5, row = i / 5;
    button(12 + col * 60, 400 + row * 40, 54, 34, String(i + 1), TFT_DARKGREEN, 2);
  }
}

void tapSudokuGame(int x, int y) {
  if (inside(8, 10, 60, 42, x, y)) { drawHome(); return; }
  if (inside(76, 10, 82, 42, x, y)) { sudokuDifficulty = (sudokuDifficulty + 1) % 3; drawSudokuGame(); return; }
  if (inside(246, 10, 66, 42, x, y)) { drawSudokuGame(); return; }
  if (inside(SUDOKU_X, SUDOKU_Y, 9 * SUDOKU_CELL, 9 * SUDOKU_CELL, x, y)) {
    int col = (x - SUDOKU_X) / SUDOKU_CELL;
    int row = (y - SUDOKU_Y) / SUDOKU_CELL;
    if (sudokuStart[row][col] == 0) {
      if (sudokuSelectedCol >= 0) drawSudokuCell(sudokuSelectedCol, sudokuSelectedRow);
      sudokuSelectedCol = col; sudokuSelectedRow = row;
      drawSudokuCell(col, row);
      sudokuMessage = "Now choose a number below";
      drawSudokuStatus();
    }
    return;
  }
  for (int i = 0; i < 9; i++) {
    int col = i % 5, row = i / 5;
    if (!inside(12 + col * 60, 400 + row * 40, 54, 34, x, y) || sudokuSelectedCol < 0) continue;
    int answer = i + 1;
    if (answer == sudokuSolution[sudokuSelectedRow][sudokuSelectedCol]) {
      sudokuBoard[sudokuSelectedRow][sudokuSelectedCol] = answer;
      drawSudokuCell(sudokuSelectedCol, sudokuSelectedRow);
      sudokuMessage = "Correct! Pick another empty square";
      sudokuSelectedCol = sudokuSelectedRow = -1;
    } else sudokuMessage = "That number does not fit there";
    drawSudokuStatus();
    return;
  }
}

void drawSizePage() {
  page = SIZE_PAGE;
  tft.fillScreen(TFT_BLACK);
  tft.fillRect(0, 0, SCREEN_W, 74, TFT_NAVY);
  button(10, 14, 70, 42, "< HOME", TFT_BLUE, 1);
  label("NEW CANVAS", 202, 27, 4, TFT_WHITE, TFT_NAVY);
  label("Choose your canvas size", 202, 55, 2, TFT_CYAN, TFT_NAVY);
  button(35, 105, 250, 76, "8 x 8", TFT_DARKGREEN, 4);
  button(35, 205, 250, 76, "16 x 16", TFT_BLUE, 4);
  button(35, 305, 250, 76, "32 x 32", TFT_PURPLE, 4);
  label("Each size starts a blank new picture", 160, 425, 2, TFT_LIGHTGREY, TFT_BLACK);
}

void drawCell(int index) {
  int col = index % gridSize;
  int row = index / gridSize;
  int scaledCell = max(1, (int)(cellSize * canvasZoom));
  int x = canvasX + col * scaledCell - canvasPanX;
  int y = canvasY + row * scaledCell - canvasPanY;
  if (x + scaledCell <= canvasX || x >= canvasX + canvasSize || y + scaledCell <= canvasY || y >= canvasY + canvasSize) return;
  tft.fillRect(x, y, scaledCell, scaledCell, art[index]);
}

void drawCanvas() {
  tft.fillRoundRect(10, 70, 300, 300, 10, TFT_DARKGREY);
  tft.fillRect(canvasX - 1, canvasY - 1, canvasSize + 2, canvasSize + 2, TFT_WHITE);
  // drawCell already clips every pixel to the canvas. Drawing in normal screen
  // coordinates keeps saved artwork aligned with the blank canvas at (16, 76).
  tft.fillRect(canvasX, canvasY, canvasSize, canvasSize, TFT_BLACK);
  for (int i = 0; i < gridSize * gridSize; i++) drawCell(i);
}

void drawStudioHeader() {
  tft.fillRect(0, 0, SCREEN_W, 61, TFT_NAVY);
  button(5, 9, 112, 42, activeSlot < 0 ? "NAME" : slotName(activeSlot), TFT_BLUE, 1);
  button(124, 9, 91, 42, "COLOURS", TFT_PURPLE, 2);
  button(222, 9, 86, 42, "SAVE", TFT_DARKGREEN, 2);
}

void drawTools() {
  // Seven slim, separate tool buttons fit below the artwork without covering it.
  button(3, 397, 43, 61, "HOME", TFT_BLUE, 1);
  button(48, 397, 43, 61, "UNDO", undoCount ? TFT_DARKGREEN : TFT_DARKGREY, 1);
  button(93, 397, 43, 61, "REDO", redoCount ? TFT_DARKGREEN : TFT_DARKGREY, 1);
  button(138, 397, 43, 61, "ERASE", eraser ? TFT_ORANGE : TFT_MAROON, 1);
  button(183, 397, 43, 61, "PICK", eyedropper ? TFT_CYAN : TFT_BLUE, 1);
  button(228, 397, 43, 61, "FILL", TFT_PURPLE, 1);
  button(273, 397, 44, 61, "CLEAR", TFT_RED, 1);
}

void drawStudio() {
  page = STUDIO_PAGE;
  tft.fillScreen(TFT_BLACK);
  drawStudioHeader();
  drawCanvas();
  drawTools();
}

void drawThumbnail(int slot, int x, int y, int w, int h) {
  tft.fillRoundRect(x, y, w, h, 12, TFT_DARKGREY);
  tft.drawRoundRect(x, y, w, h, 12, TFT_WHITE);
  if (!slotExists(slot)) {
    label("EMPTY", x + w / 2, y + h / 2 - 8, 2, TFT_LIGHTGREY, TFT_DARKGREY);
    label("save art here", x + w / 2, y + h / 2 + 17, 1, TFT_LIGHTGREY, TFT_DARKGREY);
    return;
  }
  uint8_t savedSize = prefs.getUChar(sizeKey(slot).c_str(), 16);
  uint16_t preview[MAX_PIXELS];
  for (int i = 0; i < MAX_PIXELS; i++) preview[i] = TFT_BLACK;
  prefs.getBytes(artKey(slot).c_str(), preview, savedSize * savedSize * sizeof(uint16_t));
  int drawSize = 74;
  int pixel = drawSize / savedSize;
  int px = x + (w - drawSize) / 2;
  int py = y + 12;
  for (int r = 0; r < savedSize; r++) for (int c = 0; c < savedSize; c++)
    tft.fillRect(px + c * pixel, py + r * pixel, pixel, pixel, preview[r * savedSize + c]);
  label(slotName(slot), x + w / 2, y + h - 16, 1, TFT_CYAN, TFT_DARKGREY);
}

void drawGallery() {
  page = GALLERY_PAGE;
  tft.fillScreen(TFT_BLACK);
  tft.fillRect(0, 0, SCREEN_W, 63, TFT_NAVY);
  button(8, 11, 70, 41, "< HOME", TFT_BLUE, 1);
  label("MY GALLERY", 202, 28, 4, TFT_WHITE, TFT_NAVY);
  label(String("PAGE ") + String(galleryPage + 1) + " OF 2", 160, 78, 2, galleryDeleteMode ? TFT_ORANGE : TFT_CYAN, TFT_BLACK);
  for (int cell = 0; cell < GALLERY_SLOTS_PER_PAGE; cell++) {
    int slot = galleryPage * GALLERY_SLOTS_PER_PAGE + cell;
    int col = cell % 2, row = cell / 2;
    drawThumbnail(slot, 15 + col * 150, 96 + row * 118, 140, 106);
    if (galleryDeleteMode && slotExists(slot)) {
      tft.fillRoundRect(111 + col * 150, 101 + row * 118, 38, 24, 7, TFT_RED);
      label("X", 130 + col * 150, 113 + row * 118, 2, TFT_WHITE, TFT_RED);
    }
  }
  button(5, 451, 73, 25, "< PAGE", TFT_BLUE, 1);
  button(82, 451, 74, 25, galleryDeleteMode ? "DONE" : "DELETE", galleryDeleteMode ? TFT_DARKGREEN : TFT_RED, 1);
  button(160, 451, 73, 25, "PAGE >", TFT_BLUE, 1);
  button(237, 451, 78, 25, "+ NEW", TFT_DARKGREEN, 1);
}

void drawArtNameDisplay() {
  // Only this small box changes when a letter is typed or removed.
  tft.fillRoundRect(18, 76, 284, 43, 9, TFT_DARKGREY);
  tft.drawRoundRect(18, 76, 284, 43, 9, TFT_WHITE);
  label(artNameBuffer.length() ? artNameBuffer : "Tap letters below", 160, 98, 2, TFT_WHITE, TFT_DARKGREY);
}

void drawArtNameKey(int keyIndex, bool selected) {
  // Familiar phone-style QWERTY rows: 10 keys, then 9, then 7.
  int x, y, w, h;
  String text;
  uint16_t fill;
  const char *letters = "QWERTYUIOPASDFGHJKLZXCVBNM";
  if (keyIndex >= 0 && keyIndex < 10) {
    x = 3 + keyIndex * 32; y = 140; w = 29; h = 44;
    text = String(letters[keyIndex]); fill = TFT_PURPLE;
  } else if (keyIndex >= 10 && keyIndex < 19) {
    x = 16 + (keyIndex - 10) * 32; y = 193; w = 29; h = 44;
    text = String(letters[keyIndex]); fill = TFT_PURPLE;
  } else if (keyIndex >= 19 && keyIndex < 26) {
    x = 48 + (keyIndex - 19) * 32; y = 246; w = 29; h = 44;
    text = String(letters[keyIndex]); fill = TFT_PURPLE;
  } else if (keyIndex == 26) {
    x = 18; y = 370; w = 90; h = 48; text = "SPACE"; fill = TFT_BLUE;
  } else if (keyIndex == 27) {
    x = 116; y = 370; w = 90; h = 48; text = "BACK"; fill = TFT_ORANGE;
  } else return;
  button(x, y, w, h, text, fill, keyIndex < 26 ? 2 : 1);
  if (selected) tft.drawRoundRect(x + 2, y + 2, w - 4, h - 4, 8, TFT_YELLOW);
}

void drawArtNamePage() {
  page = ART_NAME_PAGE;
  lastArtNameKey = -1;
  heldArtNameKey = -1;
  artNameKeyReleaseAt = 0;
  tft.fillScreen(TFT_BLACK);
  tft.fillRect(0, 0, SCREEN_W, 63, TFT_NAVY);
  button(8, 10, 76, 42, "< BACK", TFT_BLUE, 1);
  label("NAME ART", 206, 26, 4, TFT_WHITE, TFT_NAVY);
  drawArtNameDisplay();
  for (int i = 0; i < 26; i++) drawArtNameKey(i, false);
  drawArtNameKey(26, false);
  drawArtNameKey(27, false);
  button(214, 370, 88, 48, "SAVE", TFT_DARKGREEN, 1);
  label("Names can be up to 14 letters", 160, 447, 2, TFT_LIGHTGREY, TFT_BLACK);
}

void drawColourPage() {
  page = COLOUR_PAGE;
  tft.fillScreen(TFT_BLACK);
  tft.fillRect(0, 0, SCREEN_W, 63, TFT_NAVY);
  button(10, 11, 70, 41, "< BACK", TFT_BLUE, 1);
  label("160 COLOURS", 204, 28, 4, TFT_WHITE, TFT_NAVY);
  label("Colours plus black-to-white column", 160, 78, 2, TFT_CYAN, TFT_BLACK);
  const int x0 = 0, y0 = 99, sw = 20, sh = 20;
  for (int n = 0; n < PALETTE_COUNT; n++) {
    int x = x0 + (n % PALETTE_COLUMNS) * sw;
    int y = y0 + (n / PALETTE_COLUMNS) * sh;
    tft.fillRect(x, y, sw - 1, sh - 1, paletteColour(n));
    if (n == selectedColour && !eraser) tft.drawRect(x, y, sw - 1, sh - 1, TFT_RED);
  }
  uint16_t chosen = eraser ? TFT_BLACK : paletteColour(selectedColour);
  tft.fillRoundRect(60, 325, 200, 62, 12, chosen);
  tft.drawRoundRect(60, 325, 200, 62, 12, TFT_WHITE);
  label(eraser ? "ERASER SELECTED" : "SELECTED PAINT", 160, 356, 2, chosen == TFT_WHITE ? TFT_BLACK : TFT_WHITE, chosen);
  label("15 colour columns + black-to-white", 160, 421, 2, TFT_LIGHTGREY, TFT_BLACK);
}

void drawSkinCell(int index) {
  int col = index % SKIN_COLS;
  int row = index / SKIN_COLS;
  tft.fillRect(SKIN_X + col * SKIN_CELL, SKIN_Y + row * SKIN_CELL, SKIN_CELL - 1, SKIN_CELL - 1, skin[index]);
}

void saveSkin() {
  prefs.putBytes("blockskin", skin, sizeof(skin));
}

void drawSkinMaker() {
  page = SKIN_PAGE;
  tft.fillScreen(TFT_BLACK);
  tft.fillRect(0, 0, SCREEN_W, 63, TFT_NAVY);
  button(8, 10, 74, 42, "HOME", TFT_BLUE, 1);
  label("SKIN MAKER", 204, 25, 4, TFT_WHITE, TFT_NAVY);
  tft.fillRoundRect(78, 66, 164, 294, 10, TFT_DARKGREY);
  for (int i = 0; i < SKIN_PIXELS; i++) drawSkinCell(i);
  label("16 x 32 BLOCK CHARACTER", 160, 374, 2, TFT_CYAN, TFT_BLACK);
  button(15, 397, 138, 61, "160 COLOURS", TFT_PURPLE, 2);
  button(167, 397, 138, 61, "SAVE SKIN", TFT_DARKGREEN, 2);
  label("Paint each square to make your 2D skin", 160, 470, 1, TFT_LIGHTGREY, TFT_BLACK);
}

void drawSkinColours() {
  page = SKIN_COLOUR_PAGE;
  tft.fillScreen(TFT_BLACK);
  tft.fillRect(0, 0, SCREEN_W, 63, TFT_NAVY);
  button(8, 10, 76, 42, "< BACK", TFT_BLUE, 1);
  label("SKIN COLOURS", 205, 26, 4, TFT_WHITE, TFT_NAVY);
  label("Colours plus black-to-white column", 160, 79, 2, TFT_CYAN, TFT_BLACK);
  for (int n = 0; n < PALETTE_COUNT; n++) {
    int x = (n % PALETTE_COLUMNS) * 20;
    int y = 103 + (n / PALETTE_COLUMNS) * 20;
    uint16_t colour = paletteColour(n);
    tft.fillRoundRect(x + 1, y + 1, 17, 17, 3, colour);
    if (skinColour == n) tft.drawRoundRect(x, y, 19, 19, 3, TFT_RED);
  }
  tft.fillRoundRect(65, 332, 190, 54, 12, paletteColour(skinColour));
  tft.drawRoundRect(65, 332, 190, 54, 12, TFT_WHITE);
  label("SELECTED PAINT", 160, 359, 2, paletteColour(skinColour) == TFT_WHITE ? TFT_BLACK : TFT_WHITE, paletteColour(skinColour));
}

void tapSkinMaker(int x, int y) {
  if (inside(8, 10, 74, 42, x, y)) { drawHome(); return; }
  if (inside(SKIN_X, SKIN_Y, SKIN_COLS * SKIN_CELL, SKIN_ROWS * SKIN_CELL, x, y)) {
    int col = (x - SKIN_X) / SKIN_CELL;
    int row = (y - SKIN_Y) / SKIN_CELL;
    int index = row * SKIN_COLS + col;
    skin[index] = palette100Colour(skinColour);
    drawSkinCell(index); // A brush stroke changes only its one square.
    return;
  }
  if (inside(15, 397, 138, 61, x, y)) { drawSkinColours(); return; }
  if (inside(167, 397, 138, 61, x, y)) {
    saveSkin();
    tft.fillRoundRect(87, 181, 146, 54, 10, TFT_DARKGREEN);
    label("SKIN SAVED", 160, 208, 2, TFT_WHITE, TFT_DARKGREEN);
    delay(500);
    drawSkinMaker();
  }
}

void tapSkinColours(int x, int y) {
  if (inside(8, 10, 76, 42, x, y)) { drawSkinMaker(); return; }
  if (inside(0, 103, 320, 200, x, y)) {
    int col = x / 20;
    int row = (y - 103) / 20;
    int choice = row * PALETTE_COLUMNS + col;
    if (choice >= 0 && choice < PALETTE_COUNT) {
      int oldColour = skinColour;
      skinColour = choice;
      updatePaletteSelection(oldColour, skinColour, 103, true);
      tft.fillRoundRect(65, 332, 190, 54, 12, paletteColour(skinColour));
      tft.drawRoundRect(65, 332, 190, 54, 12, TFT_WHITE);
      label("SELECTED PAINT", 160, 359, 2, paletteColour(skinColour) == TFT_WHITE ? TFT_BLACK : TFT_WHITE, paletteColour(skinColour));
    }
  }
}

void saveBirdSkin() {
  prefs.putBytes("birdskin", birdSkin, sizeof(birdSkin));
}

void drawBirdEditor() {
  page = BIRD_EDITOR_PAGE;
  tft.fillScreen(TFT_BLACK);
  tft.fillRect(0, 0, SCREEN_W, 63, TFT_NAVY);
  button(8, 10, 76, 42, "< BACK", TFT_BLUE, 1);
  label("BIRD EDITOR", 204, 25, 4, TFT_WHITE, TFT_NAVY);
  label("Paint your 8 x 8 pixel bird", 160, 77, 2, TFT_CYAN, TFT_BLACK);
  const int gx = 32, gy = 102, px = 32;
  for (int row = 0; row < 8; row++) for (int col = 0; col < 8; col++)
    tft.fillRect(gx + col * px, gy + row * px, px - 2, px - 2, birdSkin[row * 8 + col]);
  label("Black squares are transparent", 160, 374, 2, TFT_LIGHTGREY, TFT_BLACK);
  button(15, 386, 140, 52, "160 COLOURS", TFT_PURPLE, 1);
  button(165, 386, 140, 52, "IMPORT ART", TFT_BLUE, 1);
  button(80, 454, 160, 24, "SAVE BIRD", TFT_DARKGREEN, 1);
}

void drawBirdColourPage() {
  page = BIRD_COLOUR_PAGE;
  tft.fillScreen(TFT_BLACK);
  tft.fillRect(0, 0, SCREEN_W, 63, TFT_NAVY);
  button(10, 11, 76, 41, "< BACK", TFT_BLUE, 1);
  label("BIRD COLOURS", 205, 27, 4, TFT_WHITE, TFT_NAVY);
  label("Colours plus black-to-white column", 160, 78, 2, TFT_CYAN, TFT_BLACK);
  const int x0 = 0, y0 = 101, sw = 20, sh = 20;
  for (int n = 0; n < PALETTE_COUNT; n++) {
    int x = x0 + (n % PALETTE_COLUMNS) * sw, y = y0 + (n / PALETTE_COLUMNS) * sh;
    uint16_t colour = paletteColour(n);
    tft.fillRoundRect(x + 1, y + 1, sw - 3, sh - 3, 3, colour);
    if (birdColour == n) tft.drawRoundRect(x, y, sw - 1, sh - 1, 3, TFT_RED);
  }
  button(65, 331, 190, 54, "BLACK / ERASE", TFT_BLACK, 2);
  label("Tap < BACK when you are done", 160, 463, 2, TFT_LIGHTGREY, TFT_BLACK);
}

void tapBirdEditor(int x, int y) {
  if (inside(8, 10, 76, 42, x, y)) { drawFlappyGame(); return; }
  if (inside(32, 102, 256, 256, x, y)) {
    int col = (x - 32) / 32, row = (y - 102) / 32;
    int index = row * 8 + col;
    birdSkin[index] = birdColour == 255 ? TFT_BLACK : palette100Colour(birdColour);
    tft.fillRect(32 + col * 32, 102 + row * 32, 30, 30, birdSkin[index]);
    return;
  }
  if (inside(15, 386, 140, 52, x, y)) { drawBirdColourPage(); return; }
  if (inside(165, 386, 140, 52, x, y)) { drawBirdImportPage(); return; }
  if (inside(80, 454, 160, 24, x, y)) { saveBirdSkin(); drawFlappyGame(); }
}

void tapBirdColourPage(int x, int y) {
  if (inside(10, 11, 76, 41, x, y)) { drawBirdEditor(); return; }
  if (inside(65, 331, 190, 54, x, y)) { birdColour = 255; drawBirdColourPage(); return; }
  if (inside(0, 101, 320, 200, x, y)) {
    int choice = ((y - 101) / 20) * PALETTE_COLUMNS + x / 20;
    if (choice >= 0 && choice < PALETTE_COUNT) {
      int oldColour = birdColour;
      birdColour = choice;
      updatePaletteSelection(oldColour, birdColour, 101, true);
    }
  }
}

// Choose a saved Pixel Studio drawing and shrink it to the bird's 8 by 8 skin.
// This works with saved 8x8, 16x16, and 32x32 art by sampling across the whole drawing.
void drawBirdImportPage() {
  page = BIRD_IMPORT_PAGE;
  tft.fillScreen(TFT_BLACK);
  tft.fillRect(0, 0, SCREEN_W, 63, TFT_NAVY);
  button(8, 10, 76, 42, "< BACK", TFT_BLUE, 1);
  label("IMPORT ART", 205, 26, 4, TFT_WHITE, TFT_NAVY);
  label("Pick a saved drawing for your bird", 160, 78, 2, TFT_CYAN, TFT_BLACK);
  for (int slot = 0; slot < SAVE_SLOTS; slot++) {
    int col = slot % 2;
    int row = slot / 2;
    drawThumbnail(slot, 15 + col * 150, 96 + row * 118, 140, 106);
  }
  label("It will be fitted into the 8 x 8 bird", 160, 458, 1, TFT_LIGHTGREY, TFT_BLACK);
}

void importSavedArtToBird(int slot) {
  if (!slotExists(slot)) return;
  uint8_t savedSize = prefs.getUChar(sizeKey(slot).c_str(), 16);
  if (sdArtExists(slot)) {
    File file = SD.open(sdArtPath(slot).c_str(), FILE_READ);
    if (file) { file.read(&savedSize, 1); file.close(); }
  }
  if (savedSize != 8 && savedSize != 16 && savedSize != 32) return;
  uint16_t imported[MAX_PIXELS];
  for (int i = 0; i < MAX_PIXELS; i++) imported[i] = TFT_BLACK;
  bool loaded = false;
  if (sdArtExists(slot)) {
    File file = SD.open(sdArtPath(slot).c_str(), FILE_READ);
    uint8_t headerSize = 0;
    if (file && file.read(&headerSize, 1) == 1 && headerSize == savedSize) {
      loaded = file.read((uint8_t *)imported, savedSize * savedSize * sizeof(uint16_t)) == savedSize * savedSize * sizeof(uint16_t);
    }
    if (file) file.close();
  }
  if (!loaded) prefs.getBytes(artKey(slot).c_str(), imported, savedSize * savedSize * sizeof(uint16_t));
  for (int row = 0; row < 8; row++) for (int col = 0; col < 8; col++) {
    int sourceCol = min(savedSize - 1, (col * savedSize) / 8);
    int sourceRow = min(savedSize - 1, (row * savedSize) / 8);
    birdSkin[row * 8 + col] = imported[sourceRow * savedSize + sourceCol];
  }
  drawBirdEditor();
}

void tapBirdImportPage(int x, int y) {
  if (inside(8, 10, 76, 42, x, y)) { drawBirdEditor(); return; }
  for (int slot = 0; slot < SAVE_SLOTS; slot++) {
    int col = slot % 2;
    int row = slot / 2;
    if (inside(15 + col * 150, 96 + row * 118, 140, 106, x, y) && slotExists(slot)) {
      importSavedArtToBird(slot);
      return;
    }
  }
}

void drawFlappyBird(int x, int y, uint16_t background) {
  // Clear the complete old 8 x 8 sprite box, then redraw the custom bird.
  const int pixel = 4;
  tft.fillRect(x - 16, y - 16, 32, 32, background);
  for (int row = 0; row < 8; row++) for (int col = 0; col < 8; col++) {
    uint16_t colour = birdSkin[row * 8 + col];
    if (colour != TFT_BLACK) tft.fillRect(x - 16 + col * pixel, y - 16 + row * pixel, pixel, pixel, colour);
  }
}

void drawFlappyPipe(int x, int gapY) {
  // The gap gets tighter as the score rises, but never becomes unfairly small.
  int currentGap = max(FLAPPY_MIN_GAP_H, FLAPPY_GAP_H - (flappyScore / 4) * 6);
  // Dark outlines, highlights, rim caps, and seams make both pipes readable.
  int topEnd = gapY - currentGap / 2;
  int bottomStart = gapY + currentGap / 2;
  tft.fillRect(x - 1, FLAPPY_TOP, FLAPPY_PIPE_W + 2, topEnd - FLAPPY_TOP - 10, TFT_DARKGREEN);
  tft.fillRect(x + 3, FLAPPY_TOP, FLAPPY_PIPE_W - 8, topEnd - FLAPPY_TOP - 10, TFT_GREEN);
  tft.fillRect(x + 8, FLAPPY_TOP, 5, topEnd - FLAPPY_TOP - 10, TFT_LIGHTGREY);
  tft.fillRoundRect(x - 6, topEnd - 14, FLAPPY_PIPE_W + 12, 14, 3, TFT_DARKGREEN);
  tft.fillRoundRect(x - 3, topEnd - 12, FLAPPY_PIPE_W + 6, 10, 2, TFT_GREEN);
  tft.drawFastHLine(x - 1, topEnd - 4, FLAPPY_PIPE_W + 2, tft.color565(40, 125, 45));
  tft.fillRoundRect(x - 1, bottomStart, FLAPPY_PIPE_W + 2, FLAPPY_GROUND - bottomStart, 2, TFT_DARKGREEN);
  tft.fillRect(x + 3, bottomStart + 10, FLAPPY_PIPE_W - 8, FLAPPY_GROUND - bottomStart - 10, TFT_GREEN);
  tft.fillRect(x + 8, bottomStart + 10, 5, FLAPPY_GROUND - bottomStart - 10, TFT_LIGHTGREY);
  tft.fillRoundRect(x - 6, bottomStart, FLAPPY_PIPE_W + 12, 14, 3, TFT_DARKGREEN);
  tft.fillRoundRect(x - 3, bottomStart + 2, FLAPPY_PIPE_W + 6, 10, 2, TFT_GREEN);
  tft.drawFastHLine(x - 1, bottomStart + 12, FLAPPY_PIPE_W + 2, tft.color565(40, 125, 45));
}

void drawFlappyCoin(int x, int y) {
  // A coin sits in the safe opening and travels with its pipe.
  tft.fillCircle(x, y, 9, TFT_ORANGE);
  tft.drawCircle(x, y, 9, TFT_YELLOW);
  tft.fillCircle(x, y, 5, TFT_YELLOW);
  tft.drawFastVLine(x, y - 3, 7, TFT_ORANGE);
}

void drawFlappyPowerUp(int x, int y, int type) {
  uint16_t colour = type == 0 ? TFT_MAGENTA : (type == 1 ? TFT_CYAN : TFT_YELLOW);
  tft.fillCircle(x, y, 11, TFT_DARKGREY);
  tft.drawCircle(x, y, 11, TFT_WHITE);
  tft.fillCircle(x, y, 8, colour);
  // + gives points, S slows the pipes, and ! gives one shield.
  label(type == 0 ? "+" : (type == 1 ? "S" : "!"), x, y + 1, 2, TFT_WHITE, colour);
}

// Restore only a narrow strip of the fixed sky and ground after a pipe moves.
// This avoids clearing and repainting the whole play area every frame.
void restoreFlappyBackgroundStrip(int x, int w) {
  if (w <= 0 || x >= SCREEN_W || x + w <= 0) return;
  int left = max(0, x);
  int right = min(SCREEN_W, x + w);
  int width = right - left;
  tft.fillRect(left, FLAPPY_TOP, width, FLAPPY_GROUND - FLAPPY_TOP, TFT_SKYBLUE);
  tft.fillRect(left, FLAPPY_GROUND, width, SCREEN_H - FLAPPY_GROUND, TFT_DARKGREEN);
  tft.fillRect(left, FLAPPY_GROUND, width, 7, TFT_GREEN);
  tft.drawFastHLine(left, FLAPPY_GROUND + 15, width, tft.color565(35, 95, 35));
  tft.drawFastHLine(left, FLAPPY_GROUND + 32, width, tft.color565(35, 95, 35));
}

void drawFlappyGame() {
  page = FLAPPY_PAGE;
  flappyBirdY = 210; flappyOldY = 210; flappyVelocityY = 0;
  flappyPipeX = 270; flappyGapY = random(145, 285);
  flappyCoinX = flappyPipeX + FLAPPY_PIPE_W / 2;
  flappyCoinY = flappyGapY;
  flappyPowerX = flappyPipeX + 12;
  flappyPowerY = flappyGapY - 32;
  flappyPowerType = random(3);
  flappyPipePassed = false; flappyCoinCollected = false; flappyPowerCollected = false;
  flappySlowFrames = 0; flappyShield = false;
  flappyScore = 0; flappyRunning = false; flappyOver = false;
  flappyBest = prefs.getInt("flappybest", 0);
  tft.fillScreen(TFT_SKYBLUE);
  tft.fillRect(0, 0, SCREEN_W, 63, TFT_NAVY);
  button(8, 10, 55, 42, "HOME", TFT_BLUE, 1);
  // This opens the bird editor; it stays separate from HOME and the score card.
  button(68, 10, 62, 42, "BIRD", TFT_PURPLE, 1);
  label("FLAPPY", 177, 23, 4, TFT_WHITE, TFT_NAVY);
  label("Tap to flap through pipes", 177, 48, 1, TFT_CYAN, TFT_NAVY);
  tft.fillRect(0, FLAPPY_GROUND, SCREEN_W, SCREEN_H - FLAPPY_GROUND, TFT_DARKGREEN);
  tft.fillRect(0, FLAPPY_GROUND, SCREEN_W, 7, TFT_GREEN);
  tft.drawFastHLine(0, FLAPPY_GROUND + 15, SCREEN_W, tft.color565(35, 95, 35));
  tft.drawFastHLine(0, FLAPPY_GROUND + 32, SCREEN_W, tft.color565(35, 95, 35));
  drawFlappyPipe(flappyPipeX, flappyGapY);
  drawFlappyCoin(flappyCoinX, flappyCoinY);
  drawFlappyPowerUp(flappyPowerX, flappyPowerY, flappyPowerType);
  drawFlappyBird(flappyBirdX, flappyBirdY, TFT_SKYBLUE);
  // Score lives in the navy header, outside the moving pipe area.
  tft.fillRoundRect(235, 7, 80, 49, 8, TFT_DARKGREY);
  label("SCORE 0", 275, 22, 1, TFT_WHITE, TFT_DARKGREY);
  label("BEST " + String(flappyBest), 275, 42, 1, TFT_CYAN, TFT_DARKGREY);
  label("TAP ANYWHERE TO START", 160, 220, 2, TFT_NAVY, TFT_SKYBLUE);
}

void tapFlappyGame(int x, int y) {
  if (inside(8, 10, 55, 42, x, y)) { drawHome(); return; }
  if (inside(68, 10, 55, 42, x, y)) { drawBirdEditor(); return; }
  if (flappyOver) { drawFlappyGame(); return; }
  if (!flappyRunning) {
    // Remove only the ready prompt before the first flap.
    tft.fillRect(25, 207, 220, 28, TFT_SKYBLUE);
    flappyRunning = true;
  }
  flappyVelocityY = -5.8f;
}

void updateFlappyGame() {
  if (!flappyRunning || flappyOver || millis() - flappyLastFrame < 45) return;
  flappyLastFrame = millis();
  flappyOldY = flappyBirdY;
  flappyVelocityY += 0.42f;
  flappyBirdY += (int)flappyVelocityY;

  // Restore the old pillar area, then draw the complete pillar at its new spot.
  // This is the earlier, more reliable method: the pillars stay solid and visible.
  int oldPipeX = flappyPipeX;
  int oldCoinX = flappyCoinX;
  int oldPowerX = flappyPowerX;
  int pipeSpeed = flappySlowFrames > 0 ? 1 : 3;
  flappyPipeX -= pipeSpeed;
  flappyCoinX -= pipeSpeed;
  flappyPowerX -= pipeSpeed;
  if (flappySlowFrames > 0) flappySlowFrames--;

  // Remove the bird's previous pixels before restoring anything behind it.
  tft.fillRect(flappyBirdX - 17, flappyOldY - 17, 34, 34, TFT_SKYBLUE);
  restoreFlappyBackgroundStrip(oldPipeX - 6, FLAPPY_PIPE_W + 12);
  drawFlappyPipe(flappyPipeX, flappyGapY);

  // Coins and power-ups are small moving objects in the pipe opening.
  if (!flappyCoinCollected) tft.fillRect(oldCoinX - 11, flappyCoinY - 11, 23, 23, TFT_SKYBLUE);
  if (!flappyPowerCollected) tft.fillRect(oldPowerX - 13, flappyPowerY - 13, 27, 27, TFT_SKYBLUE);
  if (!flappyCoinCollected) drawFlappyCoin(flappyCoinX, flappyCoinY);
  if (!flappyPowerCollected) drawFlappyPowerUp(flappyPowerX, flappyPowerY, flappyPowerType);

  // Restore only the part of a pipe that was behind the old bird sprite.
  tft.setViewport(flappyBirdX - 17, flappyOldY - 17, 34, 34, false);
  drawFlappyPipe(flappyPipeX, flappyGapY);
  tft.resetViewport();

  int currentGap = max(FLAPPY_MIN_GAP_H, FLAPPY_GAP_H - (flappyScore / 4) * 6);
  bool inPipeX = flappyBirdX + 14 > flappyPipeX && flappyBirdX - 14 < flappyPipeX + FLAPPY_PIPE_W;
  bool hitPipe = inPipeX && (flappyBirdY - 14 < flappyGapY - currentGap / 2 || flappyBirdY + 14 > flappyGapY + currentGap / 2);
  bool gotCoin = !flappyCoinCollected && abs(flappyBirdX - flappyCoinX) < 21 && abs(flappyBirdY - flappyCoinY) < 22;
  bool gotPower = !flappyPowerCollected && abs(flappyBirdX - flappyPowerX) < 23 && abs(flappyBirdY - flappyPowerY) < 23;
  if (gotCoin) { flappyCoinCollected = true; flappyScore += 3; }
  if (gotPower) {
    flappyPowerCollected = true;
    if (flappyPowerType == 0) flappyScore += 5;
    else if (flappyPowerType == 1) flappySlowFrames = 90;
    else flappyShield = true;
  }
  if (!flappyPipePassed && flappyPipeX + FLAPPY_PIPE_W < flappyBirdX - 14) {
    flappyPipePassed = true;
    flappyScore++;
  }

  // This is drawn last so the bird stays visible when it crosses a pipe.
  drawFlappyBird(flappyBirdX, flappyBirdY, TFT_SKYBLUE);
  if (gotCoin || gotPower || flappyPipePassed) {
    tft.fillRoundRect(237, 9, 76, 25, 7, TFT_DARKGREY);
    label("SCORE " + String(flappyScore), 275, 22, 1, TFT_WHITE, TFT_DARKGREY);
    tft.fillRoundRect(237, 34, 76, 20, 7, TFT_DARKGREY);
    label(flappyShield ? "SHIELD!" : (flappySlowFrames ? "SLOW!" : "BEST " + String(flappyBest)), 275, 43, 1, TFT_CYAN, TFT_DARKGREY);
  }
  bool hitEdge = flappyBirdY - 14 < FLAPPY_TOP || flappyBirdY + 14 >= FLAPPY_GROUND;
  if ((hitPipe || hitEdge) && flappyShield) {
    flappyShield = false;
    flappyBirdY = constrain(flappyBirdY, FLAPPY_TOP + 16, FLAPPY_GROUND - 16);
    flappyVelocityY = 0;
    tft.fillRoundRect(237, 34, 76, 20, 7, TFT_DARKGREY);
    label("BEST " + String(flappyBest), 275, 43, 1, TFT_CYAN, TFT_DARKGREY);
  } else if (hitEdge || hitPipe) {
    flappyOver = true; flappyRunning = false;
    if (flappyScore > flappyBest) { flappyBest = flappyScore; prefs.putInt("flappybest", flappyBest); }
    tft.fillRoundRect(52, 190, 216, 67, 13, TFT_MAROON);
    tft.drawRoundRect(52, 190, 216, 67, 13, TFT_WHITE);
    label("GAME OVER", 160, 211, 2, TFT_WHITE, TFT_MAROON);
    label("Tap to play again", 160, 235, 2, TFT_YELLOW, TFT_MAROON);
    return;
  }
  if (flappyPipeX + FLAPPY_PIPE_W < 0) {
    flappyPipeX = SCREEN_W;
    flappyGapY = random(145, 285);
    flappyCoinX = flappyPipeX + FLAPPY_PIPE_W / 2;
    flappyCoinY = flappyGapY;
    flappyPowerX = flappyPipeX + 12;
    flappyPowerY = flappyGapY - 32;
    flappyPowerType = random(3);
    flappyCoinCollected = false;
    flappyPowerCollected = false;
    flappyPipePassed = false;
  }
}

void drawGameCell(int cell, uint16_t colour) {
  int col = cell % GAME_GRID;
  int row = cell / GAME_GRID;
  int x = GAME_X + col * GAME_CELL;
  int y = GAME_Y + row * GAME_CELL;
  tft.fillRoundRect(x + 2, y + 2, GAME_CELL - 4, GAME_CELL - 4, 6, colour);
}

void spawnGamePixel() {
  int next = random(0, GAME_GRID * GAME_GRID);
  if (GAME_GRID * GAME_GRID > 1) while (next == gamePixel) next = random(0, GAME_GRID * GAME_GRID);
  gamePixel = next;
  gameColour = paletteColour(random(PALETTE_COUNT));
  drawGameCell(gamePixel, gameColour);
}

void drawPixelGame() {
  page = PIXEL_GAME_PAGE;
  gameScore = 0;
  gameBest = prefs.getInt("popbest", 0);
  tft.fillScreen(TFT_BLACK);
  tft.fillRect(0, 0, SCREEN_W, 67, TFT_NAVY);
  button(10, 12, 72, 42, "< HOME", TFT_BLUE, 1);
  label("PIXEL POP", 207, 25, 4, TFT_WHITE, TFT_NAVY);
  label("Tap the glowing pixel!", 160, 52, 2, TFT_CYAN, TFT_NAVY);
  tft.fillRoundRect(16, 91, 288, 288, 15, TFT_DARKGREY);
  tft.fillRoundRect(20, 95, 280, 280, 12, TFT_BLACK);
  for (int i = 0; i < GAME_GRID * GAME_GRID; i++) drawGameCell(i, TFT_DARKGREY);
  tft.fillRoundRect(18, 399, 138, 54, 12, TFT_DARKGREEN);
  tft.fillRoundRect(164, 399, 138, 54, 12, TFT_PURPLE);
  label("SCORE 0", 87, 426, 2, TFT_WHITE, TFT_DARKGREEN);
  label(String("BEST ") + String(gameBest), 233, 426, 2, TFT_WHITE, TFT_PURPLE);
  spawnGamePixel();
}

void tapPixelGame(int x, int y) {
  if (inside(10, 12, 72, 42, x, y)) { drawHome(); return; }
  if (!inside(GAME_X, GAME_Y, GAME_GRID * GAME_CELL, GAME_GRID * GAME_CELL, x, y)) return;
  int col = (x - GAME_X) / GAME_CELL;
  int row = (y - GAME_Y) / GAME_CELL;
  int tapped = row * GAME_GRID + col;
  if (tapped != gamePixel) return;
  drawGameCell(gamePixel, TFT_DARKGREY);
  gameScore++;
  if (gameScore > gameBest) {
    gameBest = gameScore;
    prefs.putInt("popbest", gameBest);
  }
  tft.fillRoundRect(18, 399, 138, 54, 12, TFT_DARKGREEN);
  tft.fillRoundRect(164, 399, 138, 54, 12, TFT_PURPLE);
  label(String("SCORE ") + String(gameScore), 87, 426, 2, TFT_WHITE, TFT_DARKGREEN);
  label(String("BEST ") + String(gameBest), 233, 426, 2, TFT_WHITE, TFT_PURPLE);
  spawnGamePixel(); // Only the old and new pixels plus score cards are updated.
}

uint16_t snakeBackgroundColour(int col, int row) {
  // Plain black board: cleared snake cells always match the game area.
  (void)col;
  (void)row;
  return TFT_BLACK;
}

void drawSnakeBackgroundCell(int col, int row) {
  if (col < 0 || col >= SNAKE_COLS || row < 0 || row >= SNAKE_ROWS) return;
  tft.fillRect(SNAKE_X + col * SNAKE_CELL + 1, SNAKE_Y + row * SNAKE_CELL + 1,
               SNAKE_CELL - 2, SNAKE_CELL - 2, snakeBackgroundColour(col, row));
}

void drawSnakeCell(int col, int row, uint16_t colour) {
  if (col < 0 || col >= SNAKE_COLS || row < 0 || row >= SNAKE_ROWS) return;
  tft.fillRect(SNAKE_X + col * SNAKE_CELL + 1, SNAKE_Y + row * SNAKE_CELL + 1,
               SNAKE_CELL - 2, SNAKE_CELL - 2, colour);
}

void drawSnakeBodyCell(int col, int row) {
  if (col < 0 || col >= SNAKE_COLS || row < 0 || row >= SNAKE_ROWS) return;
  // The body starts slim and becomes wider as the snake becomes longer.
  int width = min(SNAKE_CELL - 2, 7 + snakeLength / 8);
  int x = SNAKE_X + col * SNAKE_CELL + (SNAKE_CELL - width) / 2;
  int y = SNAKE_Y + row * SNAKE_CELL + (SNAKE_CELL - width) / 2;
  tft.fillRoundRect(x, y, width, width, max(1, width / 4), TFT_DARKGREEN);
}

void drawSnakeHead(int col, int row) {
  // Draw the saved 8 by 8 picture inside the snake's head square.
  int x = SNAKE_X + col * SNAKE_CELL + 1;
  int y = SNAKE_Y + row * SNAKE_CELL + 1;
  int usable = SNAKE_CELL - 2;
  tft.fillRoundRect(x, y, usable, usable, 3, TFT_BLACK);
  for (int r = 0; r < 8; r++) for (int c = 0; c < 8; c++) {
    int px = x + (c * usable) / 8;
    int py = y + (r * usable) / 8;
    int pw = max(1, ((c + 1) * usable) / 8 - (c * usable) / 8);
    int ph = max(1, ((r + 1) * usable) / 8 - (r * usable) / 8);
    tft.fillRect(px, py, pw, ph, snakeHeadSkin[r * 8 + c]);
  }
}

void saveSnakeHead() {
  prefs.putBytes("snakehead", snakeHeadSkin, sizeof(snakeHeadSkin));
}

void drawSnakeHeadEditor() {
  page = SNAKE_HEAD_PAGE;
  tft.fillScreen(TFT_BLACK);
  tft.fillRect(0, 0, SCREEN_W, 62, TFT_NAVY);
  button(8, 10, 76, 42, "< BACK", TFT_BLUE, 1);
  label("SNAKE HEAD", 203, 25, 4, TFT_WHITE, TFT_NAVY);
  label("Paint your 8 x 8 pixel head", 160, 77, 2, TFT_CYAN, TFT_BLACK);
  const int gx = 32, gy = 102, px = 32;
  for (int r = 0; r < 8; r++) for (int c = 0; c < 8; c++) {
    tft.fillRect(gx + c * px, gy + r * px, px - 2, px - 2, snakeHeadSkin[r * 8 + c]);
  }
  label("Choose paint from the colour page", 160, 374, 2, TFT_LIGHTGREY, TFT_BLACK);
  button(25, 386, 270, 52, "160 COLOURS", TFT_PURPLE, 2);
  button(80, 454, 160, 24, "SAVE HEAD", TFT_DARKGREEN, 1);
}

void tapSnakeHeadEditor(int x, int y) {
  if (inside(8, 10, 76, 42, x, y)) { drawSnakeGame(); return; }
  if (inside(32, 102, 256, 256, x, y)) {
    int col = (x - 32) / 32, row = (y - 102) / 32;
    int index = row * 8 + col;
    snakeHeadSkin[index] = palette100Colour(snakeHeadColour);
    tft.fillRect(32 + col * 32, 102 + row * 32, 30, 30, snakeHeadSkin[index]);
    return;
  }
  if (inside(25, 386, 270, 52, x, y)) { drawSnakeHeadColourPage(); return; }
  if (inside(80, 454, 160, 24, x, y)) {
    saveSnakeHead();
    drawSnakeGame();
  }
}

void drawSnakeHeadColourPage() {
  page = SNAKE_HEAD_COLOUR_PAGE;
  tft.fillScreen(TFT_BLACK);
  tft.fillRect(0, 0, SCREEN_W, 63, TFT_NAVY);
  button(10, 11, 76, 41, "< BACK", TFT_BLUE, 1);
  label("HEAD COLOURS", 205, 27, 4, TFT_WHITE, TFT_NAVY);
  label("Colours plus black-to-white column", 160, 78, 2, TFT_CYAN, TFT_BLACK);
  const int x0 = 0, y0 = 101, sw = 20, sh = 20;
  for (int n = 0; n < PALETTE_COUNT; n++) {
    int x = x0 + (n % PALETTE_COLUMNS) * sw;
    int y = y0 + (n / PALETTE_COLUMNS) * sh;
    uint16_t colour = paletteColour(n);
    tft.fillRoundRect(x + 1, y + 1, sw - 3, sh - 3, 3, colour);
    if (snakeHeadColour == n) tft.drawRoundRect(x, y, sw - 1, sh - 1, 3, TFT_RED);
  }
  uint16_t chosen = paletteColour(snakeHeadColour);
  tft.fillRoundRect(65, 331, 190, 54, 12, chosen);
  tft.drawRoundRect(65, 331, 190, 54, 12, TFT_WHITE);
  label("SELECTED PAINT", 160, 358, 2, chosen == TFT_WHITE ? TFT_BLACK : TFT_WHITE, chosen);
  label("Tap < BACK when you are done", 160, 463, 2, TFT_LIGHTGREY, TFT_BLACK);
}

void tapSnakeHeadColourPage(int x, int y) {
  if (inside(10, 11, 76, 41, x, y)) { drawSnakeHeadEditor(); return; }
  const int x0 = 10, y0 = 101, sw = 20, sh = 20;
  if (inside(x0, y0, PALETTE_COLUMNS * sw, PALETTE_ROWS * sh, x, y)) {
    int col = (x - x0) / sw;
    int row = (y - y0) / sh;
    int colourIndex = row * PALETTE_COLUMNS + col;
    if (colourIndex >= 0 && colourIndex < PALETTE_COUNT) {
      int oldColour = snakeHeadColour;
      snakeHeadColour = colourIndex;
      updatePaletteSelection(oldColour, snakeHeadColour, 101, true);
      uint16_t chosen = paletteColour(snakeHeadColour);
      tft.fillRoundRect(65, 331, 190, 54, 12, chosen);
      tft.drawRoundRect(65, 331, 190, 54, 12, TFT_WHITE);
      label("SELECTED PAINT", 160, 358, 2, chosen == TFT_WHITE ? TFT_BLACK : TFT_WHITE, chosen);
    }
  }
}

void spawnSnakeFood() {
  bool used;
  do {
    snakeFoodX = random(SNAKE_COLS); snakeFoodY = random(SNAKE_ROWS); used = false;
    for (int i = 0; i < snakeLength; i++) if (snakeX[i] == snakeFoodX && snakeY[i] == snakeFoodY) used = true;
  } while (used);
  drawSnakeCell(snakeFoodX, snakeFoodY, TFT_RED);
}

void spawnMysteryBox() {
  if (mysteryX >= 0) drawSnakeBackgroundCell(mysteryX, mysteryY);
  bool used;
  do {
    mysteryX = random(SNAKE_COLS); mysteryY = random(SNAKE_ROWS); used = (mysteryX == snakeFoodX && mysteryY == snakeFoodY);
    for (int i = 0; i < snakeLength; i++) if (snakeX[i] == mysteryX && snakeY[i] == mysteryY) used = true;
  } while (used);
  mysteryType = random(3);
  uint16_t boxColour = mysteryType == 0 ? TFT_PURPLE : (mysteryType == 1 ? TFT_ORANGE : TFT_BLUE);
  drawSnakeCell(mysteryX, mysteryY, boxColour);
  String symbol = mysteryType == 0 ? "2X" : (mysteryType == 1 ? "+" : "!");
  label(symbol, SNAKE_X + mysteryX * SNAKE_CELL + 6, SNAKE_Y + mysteryY * SNAKE_CELL + 6, 1, TFT_WHITE, boxColour);
}

void drawSnakeScene() {
  tft.drawRoundRect(7, 75, 306, 306, 10, TFT_DARKGREY);
  for (int row = 0; row < SNAKE_ROWS; row++) for (int col = 0; col < SNAKE_COLS; col++) drawSnakeBackgroundCell(col, row);
}

void drawSnakeBackgroundPage() {
  page = SNAKE_BACKGROUND_PAGE;
  tft.fillScreen(TFT_BLACK);
  tft.fillRect(0, 0, SCREEN_W, 65, TFT_NAVY);
  button(8, 11, 76, 42, "< BACK", TFT_BLUE, 1);
  label("BACKGROUND", 208, 26, 4, TFT_WHITE, TFT_NAVY);
  label("Pick a look for your Snake board", 160, 78, 2, TFT_CYAN, TFT_BLACK);
  const char *names[4] = { "GRASS", "OCEAN", "SPACE", "SAND" };
  for (int n = 0; n < 4; n++) {
    int x = 20 + (n % 2) * 150, y = 106 + (n / 2) * 156;
    tft.fillRoundRect(x, y, 130, 126, 14, TFT_DARKGREY);
    for (int r = 0; r < 7; r++) for (int c = 0; c < 9; c++) {
      uint8_t old = snakeBackground; snakeBackground = n;
      tft.fillRect(x + 11 + c * 12, y + 12 + r * 12, 11, 11, snakeBackgroundColour(c, r));
      snakeBackground = old;
    }
    tft.drawRoundRect(x, y, 130, 126, 14, n == snakeBackground ? TFT_YELLOW : TFT_WHITE);
    label(names[n], x + 65, y + 105, 2, TFT_WHITE, TFT_DARKGREY);
  }
  label("Your choice is saved after power is off", 160, 448, 2, TFT_LIGHTGREY, TFT_BLACK);
}

void drawSnakeControls() {
  if (snakeControlMode == 0) {
    button(112, 379, 96, 43, "UP", TFT_ORANGE, 2);
    button(8, 426, 96, 50, "LEFT", TFT_GREEN, 1);
    button(112, 426, 96, 50, "DOWN", TFT_ORANGE, 1);
    button(216, 426, 96, 50, "RIGHT", TFT_GREEN, 1);
  } else if (snakeControlMode == 1) {
    // Four touch zones inside one large joystick; each zone changes direction.
    tft.fillCircle(160, 429, 48, TFT_DARKGREY);
    tft.drawCircle(160, 429, 48, TFT_WHITE);
    tft.drawCircle(160, 429, 38, TFT_CYAN);
    tft.fillCircle(160, 429, 17, TFT_BLUE);
    label("UP", 160, 394, 1, TFT_WHITE, TFT_DARKGREY);
    label("LEFT", 122, 429, 1, TFT_WHITE, TFT_DARKGREY);
    label("RIGHT", 198, 429, 1, TFT_WHITE, TFT_DARKGREY);
    label("DOWN", 160, 464, 1, TFT_WHITE, TFT_DARKGREY);
  } else {
    tft.fillRoundRect(18, 401, 284, 65, 16, TFT_DARKGREY);
    tft.drawRoundRect(18, 401, 284, 65, 16, TFT_CYAN);
    label("SWIPE ON THE GAME BOARD", 160, 423, 2, TFT_WHITE, TFT_DARKGREY);
    label("Swipe up, down, left, or right", 160, 448, 1, TFT_YELLOW, TFT_DARKGREY);
  }
}

void drawSnakeControlPage() {
  page = SNAKE_CONTROL_PAGE;
  tft.fillScreen(TFT_BLACK);
  tft.fillRect(0, 0, SCREEN_W, 65, TFT_NAVY);
  button(8, 11, 76, 42, "< BACK", TFT_BLUE, 1);
  label("CONTROLS", 207, 26, 4, TFT_WHITE, TFT_NAVY);
  label("Choose how you steer the snake", 160, 82, 2, TFT_CYAN, TFT_BLACK);
  button(28, 112, 264, 76, "ARROW PADS", TFT_DARKGREEN, 3);
  button(28, 212, 264, 76, "JOYSTICK", TFT_PURPLE, 3);
  button(28, 312, 264, 76, "SWIPE", TFT_BLUE, 3);
  label("Your choice is remembered", 160, 432, 2, TFT_LIGHTGREY, TFT_BLACK);
}

void setSnakeDirection(int dx, int dy) {
  if ((dx != 0 && snakeDirX == -dx) || (dy != 0 && snakeDirY == -dy)) return;
  snakeNextX = dx;
  snakeNextY = dy;
}

void drawSnakeGame() {
  page = SNAKE_PAGE; snakeLength = 5; snakeDirX = 1; snakeDirY = 0; snakeNextX = 1; snakeNextY = 0;
  snakeScore = 0; foodsEaten = 0; snakePendingGrowth = 0; mysteryX = -1; mysteryY = -1; mysteryType = 0; snakeTurboMoves = 0; snakeOver = false;
  // The Snake board uses one clean black background with no background picker.
  snakeBackground = 0;
  snakeBest = prefs.getInt("snakebest", 0);
  for (int i = 0; i < snakeLength; i++) { snakeX[i] = 7 - i; snakeY[i] = 12; }
  tft.fillScreen(TFT_BLACK); tft.fillRect(0, 0, SCREEN_W, 65, TFT_NAVY);
  button(5, 11, 55, 42, "HOME", TFT_BLUE, 1);
  button(65, 11, 55, 42, "HEAD", TFT_PURPLE, 1);
  button(125, 11, 80, 42, "CTRL", TFT_DARKGREEN, 1);
  tft.fillRoundRect(211, 9, 104, 46, 9, TFT_DARKGREY);
  tft.drawRoundRect(211, 9, 104, 46, 9, TFT_CYAN);
  label("SCORE 0", 263, 23, 1, TFT_WHITE, TFT_DARKGREY);
  label("BEST " + String(snakeBest), 263, 42, 1, TFT_CYAN, TFT_DARKGREY);
  drawSnakeScene();
  for (int i = snakeLength - 1; i >= 1; i--) drawSnakeBodyCell(snakeX[i], snakeY[i]);
  drawSnakeHead(snakeX[0], snakeY[0]);
  spawnSnakeFood();
  drawSnakeControls();
  snakeLastMove = millis();
}

void updateSnake() {
  int moveDelay = snakeTurboMoves > 0 ? 90 : 150;
  if (snakeOver || millis() - snakeLastMove < (unsigned long)moveDelay) return;
  snakeLastMove = millis(); snakeDirX = snakeNextX; snakeDirY = snakeNextY;
  int nx = (snakeX[0] + snakeDirX + SNAKE_COLS) % SNAKE_COLS;
  int ny = (snakeY[0] + snakeDirY + SNAKE_ROWS) % SNAKE_ROWS;
  for (int i = 0; i < snakeLength - 1; i++) if (snakeX[i] == nx && snakeY[i] == ny) { snakeOver = true; label("GAME OVER - TAP TO RESTART", 160, 230, 2, TFT_WHITE, TFT_RED); return; }
  bool ateFood = nx == snakeFoodX && ny == snakeFoodY;
  bool ateBox = nx == mysteryX && ny == mysteryY;
  // Grow by retaining the tail for this move. Larger rewards are queued and
  // added over following moves, keeping every body square connected.
  bool growThisMove = snakePendingGrowth > 0 && snakeLength < SNAKE_MAX;
  int tailX = snakeX[snakeLength - 1], tailY = snakeY[snakeLength - 1];
  if (!growThisMove) drawSnakeBackgroundCell(tailX, tailY);
  int oldLength = snakeLength;
  for (int i = oldLength - 1; i > 0; i--) { snakeX[i] = snakeX[i - 1]; snakeY[i] = snakeY[i - 1]; }
  snakeX[0] = nx; snakeY[0] = ny;
  if (growThisMove) { snakeX[oldLength] = tailX; snakeY[oldLength] = tailY; snakeLength++; snakePendingGrowth--; }
  if (snakeLength > 1) {
    // Fully remove the old custom head first, then paint the new wider body there.
    drawSnakeBackgroundCell(snakeX[1], snakeY[1]);
    drawSnakeBodyCell(snakeX[1], snakeY[1]);
  }
  drawSnakeHead(nx, ny);
  if (ateFood) {
    snakeScore += 10;
    snakePendingGrowth = min(SNAKE_MAX - snakeLength, snakePendingGrowth + 1);
    foodsEaten++; spawnSnakeFood(); if (foodsEaten % 3 == 0) spawnMysteryBox();
    // Each meal makes every visible body piece a little thicker as well as longer.
    for (int i = 1; i < snakeLength; i++) drawSnakeBodyCell(snakeX[i], snakeY[i]);
  }
  if (ateBox) {
    if (mysteryType == 0) {
      snakeScore += 60;
      snakePendingGrowth = min(SNAKE_MAX - snakeLength, snakePendingGrowth + 4); // Purple 2X: four connected pieces.
    }
    else if (mysteryType == 1) snakeScore += 100; // Orange +: a score boost.
    else { snakeScore += 30; snakeTurboMoves = 25; } // Blue !: a short turbo run.
    mysteryX = -1; mysteryY = -1;
  }
  if (snakeTurboMoves > 0) snakeTurboMoves--;
  if (snakeScore > snakeBest) { snakeBest = snakeScore; prefs.putInt("snakebest", snakeBest); }
  if (ateFood || ateBox) {
    // Update only the score card; the HOME, HEAD, and BONUS buttons stay untouched.
    tft.fillRoundRect(213, 11, 100, 42, 8, TFT_DARKGREY);
    label("SCORE " + String(snakeScore), 263, 23, 1, TFT_WHITE, TFT_DARKGREY);
    String bestLine = "BEST " + String(snakeBest);
    if (snakeTurboMoves > 0) bestLine += " TURBO";
    label(bestLine, 263, 42, 1, TFT_CYAN, TFT_DARKGREY);
  }
}

void tapSnake(int x, int y) {
  if (snakeOver) { drawSnakeGame(); return; }
  if (inside(5, 11, 55, 42, x, y)) { drawHome(); return; }
  if (inside(65, 11, 55, 42, x, y)) { drawSnakeHeadEditor(); return; }
  if (inside(125, 11, 80, 42, x, y)) { drawSnakeControlPage(); return; }
  if (snakeControlMode == 0) {
    if (inside(112, 379, 96, 43, x, y)) setSnakeDirection(0, -1);
    else if (inside(112, 426, 96, 50, x, y)) setSnakeDirection(0, 1);
    else if (inside(8, 426, 96, 50, x, y)) setSnakeDirection(-1, 0);
    else if (inside(216, 426, 96, 50, x, y)) setSnakeDirection(1, 0);
  } else if (snakeControlMode == 1 && inside(112, 381, 96, 96, x, y)) {
    int dx = x - 160, dy = y - 429;
    if (abs(dx) > abs(dy)) setSnakeDirection(dx < 0 ? -1 : 1, 0);
    else setSnakeDirection(0, dy < 0 ? -1 : 1);
  }
}

void copyArt(uint16_t *destination, const uint16_t *source) {
  memcpy(destination, source, MAX_PIXELS * sizeof(uint16_t));
}

void pushUndo() {
  if (undoCount == HISTORY_DEPTH) {
    for (int i = 1; i < HISTORY_DEPTH; i++) copyArt(undoHistory[i - 1], undoHistory[i]);
    undoCount--;
  }
  copyArt(undoHistory[undoCount++], art);
  redoCount = 0;
}

void undoAction() {
  if (!undoCount) return;
  if (redoCount == HISTORY_DEPTH) {
    for (int i = 1; i < HISTORY_DEPTH; i++) copyArt(redoHistory[i - 1], redoHistory[i]);
    redoCount--;
  }
  copyArt(redoHistory[redoCount++], art);
  copyArt(art, undoHistory[--undoCount]);
  drawCanvas(); drawTools();
}

void redoAction() {
  if (!redoCount) return;
  if (undoCount == HISTORY_DEPTH) {
    for (int i = 1; i < HISTORY_DEPTH; i++) copyArt(undoHistory[i - 1], undoHistory[i]);
    undoCount--;
  }
  copyArt(undoHistory[undoCount++], art);
  copyArt(art, redoHistory[--redoCount]);
  drawCanvas(); drawTools();
}

void showSavedNotice() {
  tft.fillRoundRect(55, 181, 210, 72, 12, TFT_DARKGREEN);
  tft.drawRoundRect(55, 181, 210, 72, 12, TFT_WHITE);
  label(String("SAVED: ") + slotName(activeSlot), 160, 207, 2, TFT_WHITE, TFT_DARKGREEN);
  label("It stays after power off", 160, 233, 2, TFT_YELLOW, TFT_DARKGREEN);
  delay(700);
  drawCanvas();
}

void saveArt() {
  if (activeSlot < 0) {
    for (int i = 0; i < SAVE_SLOTS; i++) if (!slotExists(i)) { activeSlot = i; break; }
    if (activeSlot < 0) { galleryPage = 0; drawGallery(); return; } // Choose a drawing to delete before saving another.
  }
  if (!artNameBuffer.length()) artNameBuffer = slotName(activeSlot);
  prefs.putString(nameKey(activeSlot).c_str(), artNameBuffer);
  // The microSD card is the main home for artwork; built-in memory remains a small backup.
  saveArtToCard(activeSlot);
  prefs.putUChar(sizeKey(activeSlot).c_str(), gridSize);
  prefs.putBytes(artKey(activeSlot).c_str(), art, gridSize * gridSize * sizeof(uint16_t));
  showSavedNotice();
}

void chooseSize(int newSize) {
  gridSize = newSize;
  setGeometry();
  clearArt();
  activeSlot = -1;
  artNameBuffer = "";
  selectedColour = 70;
  eraser = false;
  undoCount = 0;
  redoCount = 0;
  drawStudio();
}

void openSlot(int slot) {
  if (!slotExists(slot)) return;
  gridSize = prefs.getUChar(sizeKey(slot).c_str(), 16);
  if (sdArtExists(slot)) {
    File file = SD.open(sdArtPath(slot).c_str(), FILE_READ);
    if (file) { uint8_t storedSize = 16; if (file.read(&storedSize, 1) == 1) gridSize = storedSize; file.close(); }
  }
  if (gridSize != 8 && gridSize != 16 && gridSize != 32) gridSize = 16;
  setGeometry();
  clearArt();
  if (!loadArtFromCard(slot, gridSize)) prefs.getBytes(artKey(slot).c_str(), art, gridSize * gridSize * sizeof(uint16_t));
  activeSlot = slot;
  artNameBuffer = slotName(slot);
  eraser = false;
  undoCount = 0;
  redoCount = 0;
  drawStudio();
}

void tapStudio(int x, int y) {
  if (inside(5, 9, 112, 42, x, y)) { artNameBuffer = activeSlot < 0 ? "" : slotName(activeSlot); drawArtNamePage(); return; }
  if (inside(222, 9, 86, 42, x, y)) { saveArt(); return; }
  if (inside(124, 9, 91, 42, x, y)) { drawColourPage(); return; }
  if (inside(canvasX, canvasY, canvasSize, canvasSize, x, y)) {
    int scaledCell = max(1, (int)(cellSize * canvasZoom));
    int col = (x - canvasX + canvasPanX) / scaledCell;
    int row = (y - canvasY + canvasPanY) / scaledCell;
    if (col < 0 || col >= gridSize || row < 0 || row >= gridSize) return;
    int index = row * gridSize + col;
    if (eyedropper) {
      uint32_t brightness = ((uint32_t)(art[index] >> 11) * 255 / 31 + (uint32_t)((art[index] >> 5) & 63) * 255 / 63 + (uint32_t)(art[index] & 31) * 255 / 31) / 3;
      selectedColour = (brightness * (PALETTE_COUNT - 1)) / 255;
      eyedropper = false; eraser = false;
      drawTools();
      return;
    }
    uint16_t newColour = eraser ? TFT_BLACK : paletteColour(selectedColour);
    if (art[index] != newColour) {
      pushUndo(); art[index] = newColour;
      drawCell(index); // Only the painted pixel is redrawn.
      drawTools();
    }
    return;
  }
  if (inside(3, 397, 43, 61, x, y)) { drawHome(); return; }
  if (inside(48, 397, 43, 61, x, y)) { undoAction(); return; }
  if (inside(93, 397, 43, 61, x, y)) { redoAction(); return; }
  if (inside(138, 397, 43, 61, x, y)) { eraser = !eraser; eyedropper = false; drawTools(); return; }
  if (inside(183, 397, 43, 61, x, y)) { eyedropper = !eyedropper; eraser = false; drawTools(); return; }
  if (inside(228, 397, 43, 61, x, y)) {
    pushUndo(); uint16_t fill = eraser ? TFT_BLACK : paletteColour(selectedColour);
    for (int i = 0; i < gridSize * gridSize; i++) art[i] = fill;
    drawCanvas(); drawTools(); return;
  }
  if (inside(273, 397, 44, 61, x, y)) { pushUndo(); clearArt(); drawCanvas(); drawTools(); }
}

void tapGallery(int x, int y) {
  if (inside(8, 11, 70, 41, x, y)) { drawHome(); return; }
  if (inside(5, 451, 73, 25, x, y)) { galleryPage = max(0, galleryPage - 1); drawGallery(); return; }
  if (inside(82, 451, 74, 25, x, y)) { galleryDeleteMode = !galleryDeleteMode; drawGallery(); return; }
  if (inside(160, 451, 73, 25, x, y)) { galleryPage = min(1, galleryPage + 1); drawGallery(); return; }
  if (inside(237, 451, 78, 25, x, y)) { drawSizePage(); return; }
  for (int cell = 0; cell < GALLERY_SLOTS_PER_PAGE; cell++) {
    int slot = galleryPage * GALLERY_SLOTS_PER_PAGE + cell;
    int col = cell % 2, row = cell / 2;
    int x0 = 15 + col * 150, y0 = 96 + row * 118;
    if (!inside(x0, y0, 140, 106, x, y) || !slotExists(slot)) continue;
    if (galleryDeleteMode) {
      String path = sdArtPath(slot);
      if (sdCardReady && SD.exists(path.c_str())) SD.remove(path.c_str());
      prefs.remove(artKey(slot).c_str()); prefs.remove(sizeKey(slot).c_str()); prefs.remove(nameKey(slot).c_str());
      drawGallery();
    } else openSlot(slot);
    return;
  }
}

void tapArtNamePage(int x, int y) {
  if (inside(8, 10, 76, 42, x, y)) { drawStudio(); return; }
  if (inside(214, 370, 88, 48, x, y)) {
    if (!artNameBuffer.length()) artNameBuffer = "My Art";
    if (activeSlot >= 0) prefs.putString(nameKey(activeSlot).c_str(), artNameBuffer);
    drawStudio(); return;
  }
  int tappedKey = -1;
  if (inside(18, 370, 90, 48, x, y)) {
    tappedKey = 26;
  } else if (inside(116, 370, 90, 48, x, y)) {
    tappedKey = 27;
  } else {
    // Match the three staggered QWERTY rows drawn above. Checking each actual
    // button prevents a tap in the gaps from entering a letter.
    const char *letters = "QWERTYUIOPASDFGHJKLZXCVBNM";
    for (int index = 0; index < 26; index++) {
      int keyX, keyY;
      if (index < 10) { keyX = 3 + index * 32; keyY = 140; }
      else if (index < 19) { keyX = 16 + (index - 10) * 32; keyY = 193; }
      else { keyX = 48 + (index - 19) * 32; keyY = 246; }
      if (inside(keyX, keyY, 29, 44, x, y)) {
        tappedKey = index;
        break;
      }
    }
  }
  if (tappedKey < 0) return;
  // A resting finger can be reported as several taps by the touch chip. Lock the
  // key BEFORE changing the name, so one physical press can add only one letter.
  if (heldArtNameKey >= 0) return;
  heldArtNameKey = tappedKey;
  if (tappedKey == 26) {
    if (artNameBuffer.length() < 14) artNameBuffer += " ";
  } else if (tappedKey == 27) {
    if (artNameBuffer.length()) artNameBuffer.remove(artNameBuffer.length() - 1);
  } else {
    const char *letters = "QWERTYUIOPASDFGHJKLZXCVBNM";
    if (artNameBuffer.length() < 14) artNameBuffer += letters[tappedKey];
  }
  // Redraw the prior key, the one just tapped, and the text box—never the whole keyboard.
  if (lastArtNameKey >= 0 && lastArtNameKey != tappedKey) drawArtNameKey(lastArtNameKey, false);
  drawArtNameKey(tappedKey, true);
  lastArtNameKey = tappedKey;
  drawArtNameDisplay();
}

void tapColour(int x, int y) {
  if (inside(10, 11, 70, 41, x, y)) { drawStudio(); return; }
  const int x0 = 10, y0 = 99, sw = 20, sh = 20;
  if (inside(x0, y0, PALETTE_COLUMNS * sw, PALETTE_ROWS * sh, x, y)) {
    int col = (x - x0) / sw;
    int row = (y - y0) / sh;
    int colour = row * PALETTE_COLUMNS + col;
    if (colour >= 0 && colour < PALETTE_COUNT) {
      int oldColour = selectedColour;
      selectedColour = colour;
      eraser = false;
      updatePaletteSelection(oldColour, selectedColour, 99, false);
      uint16_t chosen = paletteColour(selectedColour);
      tft.fillRoundRect(60, 325, 200, 62, 12, chosen);
      tft.drawRoundRect(60, 325, 200, 62, 12, TFT_WHITE);
      label("SELECTED PAINT", 160, 356, 2, chosen == TFT_WHITE ? TFT_BLACK : TFT_WHITE, chosen);
    }
  }
}

void handleTap(int x, int y) {
  if (page == HOME_PAGE) {
    // The top arrows page through the launcher; each page holds no more than nine apps.
    if (inside(268, 9, 44, 42, x, y)) { homeAppPage = (homeAppPage + 1) % 2; drawHome(); }
    else if (inside(8, 9, 44, 42, x, y)) { homeAppPage = (homeAppPage + 1) % 2; drawHome(); }
    else if (homeAppPage == 1 && inside(20, 92, 130, 130, x, y)) drawStorageApp();
    else if (homeAppPage == 1 && inside(170, 92, 130, 130, x, y)) drawSettingsApp();
    else if (homeAppPage == 0 && inside(10, 78, 92, 83, x, y)) drawSizePage();
    else if (homeAppPage == 0 && inside(114, 78, 92, 83, x, y)) drawGallery();
    else if (homeAppPage == 0 && inside(218, 78, 92, 83, x, y)) drawSnakeGame();
    else if (homeAppPage == 0 && inside(10, 177, 92, 83, x, y)) drawFlappyGame();
    else if (homeAppPage == 0 && inside(114, 177, 92, 83, x, y)) drawCalculator();
    else if (homeAppPage == 0 && inside(218, 177, 92, 83, x, y)) drawResistorCalculator();
    else if (homeAppPage == 0 && inside(10, 286, 92, 83, x, y)) drawCheckersGame();
    else if (homeAppPage == 0 && inside(114, 286, 92, 83, x, y)) drawSudokuGame();
    else if (homeAppPage == 0 && inside(218, 286, 92, 83, x, y)) drawChessGame();
  } else if (page == SIZE_PAGE) {
    if (inside(10, 14, 70, 42, x, y)) drawHome();
    else if (inside(35, 105, 250, 76, x, y)) chooseSize(8);
    else if (inside(35, 205, 250, 76, x, y)) chooseSize(16);
    else if (inside(35, 305, 250, 76, x, y)) chooseSize(32);
  } else if (page == GALLERY_PAGE) {
    tapGallery(x, y);
  } else if (page == STUDIO_PAGE) {
    tapStudio(x, y);
  } else if (page == ART_NAME_PAGE) {
    tapArtNamePage(x, y);
  } else if (page == COLOUR_PAGE) {
    tapColour(x, y);
  } else if (page == PIXEL_GAME_PAGE) {
    tapPixelGame(x, y);
  } else if (page == SKIN_PAGE) {
    tapSkinMaker(x, y);
  } else if (page == SKIN_COLOUR_PAGE) {
    tapSkinColours(x, y);
  } else if (page == SNAKE_HEAD_PAGE) {
    tapSnakeHeadEditor(x, y);
  } else if (page == SNAKE_HEAD_COLOUR_PAGE) {
    tapSnakeHeadColourPage(x, y);
  } else if (page == BIRD_EDITOR_PAGE) {
    tapBirdEditor(x, y);
  } else if (page == BIRD_COLOUR_PAGE) {
    tapBirdColourPage(x, y);
  } else if (page == BIRD_IMPORT_PAGE) {
    tapBirdImportPage(x, y);
  } else if (page == SNAKE_CONTROL_PAGE) {
    if (inside(8, 11, 76, 42, x, y)) drawSnakeGame();
    else if (inside(28, 112, 264, 76, x, y)) { snakeControlMode = 0; prefs.putUChar("snakectrl", snakeControlMode); drawSnakeGame(); }
    else if (inside(28, 212, 264, 76, x, y)) { snakeControlMode = 1; prefs.putUChar("snakectrl", snakeControlMode); drawSnakeGame(); }
    else if (inside(28, 312, 264, 76, x, y)) { snakeControlMode = 2; prefs.putUChar("snakectrl", snakeControlMode); drawSnakeGame(); }
  } else if (page == FLAPPY_PAGE) {
    tapFlappyGame(x, y);
  } else if (page == CALCULATOR_PAGE) {
    tapCalculator(x, y);
  } else if (page == RESISTOR_PAGE) {
    tapResistorCalculator(x, y);
  } else if (page == CHESS_PAGE) {
    tapChessGame(x, y);
  } else if (page == CHECKERS_PAGE) {
    tapCheckersGame(x, y);
  } else if (page == SUDOKU_PAGE) {
    tapSudokuGame(x, y);
  } else if (page == STORAGE_PAGE) {
    tapStorageApp(x, y);
  } else if (page == SETTINGS_PAGE) {
    tapSettingsApp(x, y);
  } else if (page == SNAKE_BACKGROUND_PAGE) {
    if (inside(8, 11, 76, 42, x, y)) drawSnakeGame();
    else if (inside(20, 106, 130, 126, x, y)) { snakeBackground = 0; prefs.putUChar("snakebg", snakeBackground); drawSnakeBackgroundPage(); }
    else if (inside(170, 106, 130, 126, x, y)) { snakeBackground = 1; prefs.putUChar("snakebg", snakeBackground); drawSnakeBackgroundPage(); }
    else if (inside(20, 262, 130, 126, x, y)) { snakeBackground = 2; prefs.putUChar("snakebg", snakeBackground); drawSnakeBackgroundPage(); }
    else if (inside(170, 262, 130, 126, x, y)) { snakeBackground = 3; prefs.putUChar("snakebg", snakeBackground); drawSnakeBackgroundPage(); }
  } else {
    tapSnake(x, y);
  }
}

void migrateOlderSavedDrawing() {
  // Keep the prior single saved picture if Pixel Studio was used before this update.
  if (slotExists(0) || !prefs.isKey("art")) return;
  uint8_t oldSize = prefs.getUChar("size", 0);
  if (oldSize != 8 && oldSize != 16 && oldSize != 32) return;
  uint16_t oldArt[MAX_PIXELS];
  for (int i = 0; i < MAX_PIXELS; i++) oldArt[i] = TFT_BLACK;
  prefs.getBytes("art", oldArt, oldSize * oldSize * sizeof(uint16_t));
  prefs.putUChar("sz0", oldSize);
  prefs.putBytes("art0", oldArt, oldSize * oldSize * sizeof(uint16_t));
}

void setup() {
  ledcAttach(TFT_BACKLIGHT_PIN, 5000, 8);
  ledcWrite(TFT_BACKLIGHT_PIN, screenBrightness);
  Wire.begin(TOUCH_SDA_PIN, TOUCH_SCL_PIN, 400000);
  pinMode(TOUCH_RESET_PIN, OUTPUT);
  digitalWrite(TOUCH_RESET_PIN, LOW); delay(20);
  digitalWrite(TOUCH_RESET_PIN, HIGH); delay(100);
  tft.init();
  tft.setRotation(0);
  // The board's built-in card slot has its own SPI wires, separate from the screen.
  SPI.begin(SD_SCK_PIN, SD_MISO_PIN, SD_MOSI_PIN, SD_CS_PIN);
  sdCardReady = SD.begin(SD_CS_PIN, SPI);
  prefs.begin("pixelstudio", false);
  screenBrightness = prefs.getUChar("brightness", 220);
  if (screenBrightness < 20) screenBrightness = 20;
  ledcWrite(TFT_BACKLIGHT_PIN, screenBrightness);
  migrateOlderSavedDrawing();
  if (prefs.getBytesLength("snakehead") == sizeof(snakeHeadSkin)) {
    prefs.getBytes("snakehead", snakeHeadSkin, sizeof(snakeHeadSkin));
  } else {
    // Default friendly green face, ready for the player to customise.
    for (int i = 0; i < 64; i++) snakeHeadSkin[i] = TFT_GREEN;
    snakeHeadSkin[18] = TFT_WHITE; snakeHeadSkin[21] = TFT_WHITE;
    snakeHeadSkin[26] = TFT_BLACK; snakeHeadSkin[29] = TFT_BLACK;
    snakeHeadSkin[42] = TFT_RED; snakeHeadSkin[43] = TFT_RED;
    saveSnakeHead();
  }
  if (prefs.getBytesLength("birdskin") == sizeof(birdSkin)) {
    prefs.getBytes("birdskin", birdSkin, sizeof(birdSkin));
  } else {
    // First launch starts with a complete friendly bird skin. Black pixels are
    // transparent, and BIRD at the top of the game opens the editor to change it.
    for (int i = 0; i < 64; i++) birdSkin[i] = TFT_BLACK;
    uint16_t yellow = TFT_YELLOW, gold = TFT_ORANGE, wing = tft.color565(235, 180, 0);
    // Rounded yellow body.
    birdSkin[10] = yellow; birdSkin[11] = yellow; birdSkin[12] = yellow;
    birdSkin[17] = yellow; birdSkin[18] = yellow; birdSkin[19] = yellow; birdSkin[20] = yellow; birdSkin[21] = yellow;
    birdSkin[25] = yellow; birdSkin[26] = yellow; birdSkin[27] = wing; birdSkin[28] = wing; birdSkin[29] = yellow;
    birdSkin[33] = yellow; birdSkin[34] = yellow; birdSkin[35] = wing; birdSkin[36] = wing; birdSkin[37] = yellow;
    birdSkin[42] = yellow; birdSkin[43] = yellow; birdSkin[44] = yellow;
    // Eye, orange beak, and tail details.
    birdSkin[20] = TFT_WHITE; birdSkin[21] = TFT_BLACK;
    birdSkin[22] = gold; birdSkin[30] = gold; birdSkin[31] = gold;
    birdSkin[40] = gold; birdSkin[48] = gold;
    saveBirdSkin();
  }
  if (prefs.getBytesLength("blockskin") == sizeof(skin)) {
    prefs.getBytes("blockskin", skin, sizeof(skin));
  } else {
    // A friendly block character is ready to customise on first launch.
    for (int i = 0; i < SKIN_PIXELS; i++) skin[i] = TFT_BLACK;
    for (int r = 2; r < 12; r++) for (int c = 4; c < 12; c++) skin[r * SKIN_COLS + c] = TFT_ORANGE;
    for (int r = 12; r < 22; r++) for (int c = 3; c < 13; c++) skin[r * SKIN_COLS + c] = TFT_BLUE;
    for (int r = 22; r < 32; r++) for (int c = 3; c < 7; c++) skin[r * SKIN_COLS + c] = TFT_BROWN;
    for (int r = 22; r < 32; r++) for (int c = 9; c < 13; c++) skin[r * SKIN_COLS + c] = TFT_BROWN;
    skin[5 * SKIN_COLS + 6] = TFT_WHITE; skin[5 * SKIN_COLS + 10] = TFT_WHITE;
    saveSkin();
  }
  snakeControlMode = prefs.getUChar("snakectrl", 0);
  if (snakeControlMode > 2) snakeControlMode = 0;
  clearArt();
  drawHome();
}

void loop() {
  int xs[2], ys[2];
  int touchCount = readTouches(xs, ys);
  bool rawDown = touchCount > 0;
  bool down = rawDown;
  // The GT911 can report a tiny no-touch gap while a finger is still on the glass.
  // On the naming page, treat gaps shorter than 180 ms as the same press.
  static unsigned long nameTouchMissingAt = 0;
  if (page == ART_NAME_PAGE && touchWasDown && !rawDown) {
    if (!nameTouchMissingAt) nameTouchMissingAt = millis();
    if (millis() - nameTouchMissingAt < 180) down = true;
  } else if (page == HOME_PAGE && touchWasDown && !rawDown) {
    // Ignore a tiny missing touch report so a swipe is not cut short.
    if (!homeTouchMissingAt) homeTouchMissingAt = millis();
    if (millis() - homeTouchMissingAt < 120) down = true;
  } else if ((page == CHESS_PAGE || page == CHECKERS_PAGE) && touchWasDown && !rawDown) {
    // The touch chip can briefly say a finger lifted while it is still resting
    // on the board. Keep that one press together so the yellow selected-piece
    // outline does not flash off before the player chooses a destination.
    if (!nameTouchMissingAt) nameTouchMissingAt = millis();
    if (millis() - nameTouchMissingAt < 180) down = true;
  } else if (rawDown) {
    nameTouchMissingAt = 0;
    homeTouchMissingAt = 0;
  }

  // Two fingers spread apart to zoom in; bring them together to zoom out.
  // The point halfway between the fingers stays on the same part of the artwork.
  if (page == STUDIO_PAGE && touchCount == 2) {
    float dx = xs[1] - xs[0];
    float dy = ys[1] - ys[0];
    float distance = sqrtf(dx * dx + dy * dy);
    int focusX = (xs[0] + xs[1]) / 2;
    int focusY = (ys[0] + ys[1]) / 2;
    if (!pinchActive) {
      pinchActive = true;
      pinchStartDistance = max(1.0f, distance);
      pinchStartZoom = canvasZoom;
    } else {
      float oldZoom = canvasZoom;
      zoomAt(pinchStartZoom * distance / pinchStartDistance, focusX, focusY);
      if (fabsf(canvasZoom - oldZoom) > 0.02f) {
        drawCanvas();
        drawStudioHeader();
      }
    }
    touchWasDown = true;
    return;
  }
  if (pinchActive) {
    pinchActive = false;
    drawStudioHeader();
  }
  // Swipe mode uses the distance from the first touch point. A quick movement
  // changes direction once; ordinary taps still work for HOME, HEAD, and CTRL.
  if (page == SNAKE_PAGE && snakeControlMode == 2 && down) {
    if (!touchWasDown) {
      snakeSwipeStartX = xs[0];
      snakeSwipeStartY = ys[0];
      snakeSwipeTracking = inside(SNAKE_X, SNAKE_Y, SNAKE_COLS * SNAKE_CELL, SNAKE_ROWS * SNAKE_CELL, xs[0], ys[0]);
      handleTap(xs[0], ys[0]);
    } else if (snakeSwipeTracking) {
      int dx = xs[0] - snakeSwipeStartX;
      int dy = ys[0] - snakeSwipeStartY;
      if (abs(dx) >= 22 || abs(dy) >= 22) {
        if (abs(dx) > abs(dy)) setSnakeDirection(dx < 0 ? -1 : 1, 0);
        else setSnakeDirection(0, dy < 0 ? -1 : 1);
        snakeSwipeTracking = false;
      }
    }
  } else if (down && !touchWasDown) {
    handleTap(xs[0], ys[0]);
  }
  if (!down) {
    homeSwipeMoved = false;
    homeSwipeDirection = 0;
    homeTouchMissingAt = 0;
    snakeSwipeTracking = false;
    // Require a short real release before the naming keyboard accepts this key again.
    if (page == ART_NAME_PAGE && heldArtNameKey >= 0) {
      if (!artNameKeyReleaseAt) artNameKeyReleaseAt = millis();
      if (millis() - artNameKeyReleaseAt >= 70) {
        heldArtNameKey = -1;
        artNameKeyReleaseAt = 0;
      }
    }
    // Use a longer release check for calculator keys. This ignores tiny GT911
    // no-touch gaps that happen while a finger is still resting on the glass.
    if (page == CALCULATOR_PAGE && calculatorKeyHeld) {
      if (!calculatorKeyReleaseAt) calculatorKeyReleaseAt = millis();
      if (millis() - calculatorKeyReleaseAt >= 180) {
        calculatorKeyHeld = false;
        calculatorKeyReleaseAt = 0;
      }
    }
    // Wi-Fi uses the same release check, so one press adds one character while
    // a true lift lets the same character be entered again on the next tap.
    if (page == WIFI_PAGE && wifiKeyHeld) {
      if (!wifiKeyReleaseAt) wifiKeyReleaseAt = millis();
      if (millis() - wifiKeyReleaseAt >= 120) {
        wifiKeyHeld = false;
        wifiKeyReleaseAt = 0;
      }
    }
  } else {
    artNameKeyReleaseAt = 0;
    calculatorKeyReleaseAt = 0;
    wifiKeyReleaseAt = 0;
  }
  touchWasDown = down;
  if (page == SNAKE_PAGE) updateSnake();
  if (page == FLAPPY_PAGE) updateFlappyGame();

}

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