Mirko Pavleski
Published © GPL3+

DIY ESP32 15 Puzzle game on TFT touch Dispaly

This is an extremely simple way to make this fun puzzle game, which only requires three components

BeginnerFull instructions provided2 hours283
DIY ESP32 15 Puzzle game on TFT touch Dispaly

Things used in this project

Hardware components

Espressif ESP32 Development Board - Developer Edition
Espressif ESP32 Development Board - Developer Edition
×1
TFT Touchscreen, 320x240
TFT Touchscreen, 320x240
×1
Buzzer
Buzzer
×1

Software apps and online services

Arduino IDE
Arduino IDE

Hand tools and fabrication machines

Soldering iron (generic)
Soldering iron (generic)
Solder Wire, Lead Free
Solder Wire, Lead Free

Story

Read more

Schematics

Schematic

...

Code

Code

C/C++
...
//-----------------------------------------------------
// 15 Puzzle Game  3.2"-Display
// by: mircemk
// License: GNU GPl 3.0
// Created: 2025-03-27 20:52:34
//-----------------------------------------------------

#include <SPI.h>
#include <TFT_eSPI.h>
TFT_eSPI tft = TFT_eSPI();

// Game board configuration
#define GRID_SIZE 4
#define TILE_SIZE 54     // Increased by 1 pixel
#define TILE_SPACING 2
#define GRID_START_X 10
#define GRID_START_Y 9

// Button configuration
#define BUTTON_W 100
#define BUTTON_H 50
#define START_BTN_X 110   // Centered start button for welcome screen
#define START_BTN_Y 145

// Right side menu buttons
#define MENU_BTN_W 75
#define MENU_BTN_H 71
#define MENU_BTN_X 236
#define MENU_BTN_Y1 9    // First button
#define MENU_BTN_Y2 85   // Second button
#define MENU_BTN_Y3 160   // Third button

// Sound configuration
#define SOUND_PIN 2
#define GAME_START_DELAY 1000

// Colors
#define TILE_COLOR TFT_BLUE
#define TILE_TEXT_COLOR TFT_WHITE
#define EMPTY_COLOR TFT_BLACK
#define GRID_COLOR TFT_DARKGREY

// Board colors array
const uint16_t boardColors[] = {
    0x001F,  // Blue
    0xFDA0,  // Orange
    0xF800,  // Red
    0x7BE0,  //  Olivie
    0xF81F   // Magenta
};
int currentColorIndex = 0;

// Game state variables
uint16_t pixel_x, pixel_y;
byte gameBoard[GRID_SIZE][GRID_SIZE];
int emptyTileX = GRID_SIZE - 1;
int emptyTileY = GRID_SIZE - 1;
bool gameStarted = false;
int moves = 0;
int iEnableButtons = 1;
unsigned long lastSoundTime = 0;
bool soundEnabled = true;
unsigned long lastButtonPress = 0;
#define BUTTON_DEBOUNCE_TIME 250  // 250ms debounce

// Function prototypes
void showWelcomeScreen();
void drawFrame(int size, uint16_t color);
void initializeGame();
void drawBoard();
void drawTile(int x, int y);
void drawMenuButtons();
bool isValidMove(int x, int y);
void moveTile(int x, int y);
void silentMoveTile(int x, int y);
void shuffleBoard();
void handleGameTouch();
void checkMenuButtons();
bool checkWin();
void gameWon();
void playSound(int type);
void updateMovesDisplay();
void changeBoardColor();

void setup() {
    uint16_t calibrationData[5];
    pinMode(15, OUTPUT);
    digitalWrite(15, LOW);
    Serial.begin(115200);
    
    tft.init();
    tft.setRotation(1);
    tft.fillScreen((0xFFFF));
    
    // Calibration screen
    tft.setCursor(40, 20, 2);
    tft.setTextColor(TFT_RED, TFT_WHITE);
    tft.setTextSize(2);
    tft.println("Calibration of");
    tft.setCursor(40, 60, 2);
    tft.println("Display");
    tft.setTextColor(TFT_BLACK, TFT_WHITE);
    tft.setCursor(40, 100, 2);
    tft.println("Touch");
    tft.setCursor(40, 140, 2);
    tft.println("the indicated corners");
    tft.calibrateTouch(calibrationData, TFT_GREEN, TFT_RED, 15);
    
    showWelcomeScreen();
}

void loop() {
    static uint16_t color;
    if (tft.getTouch(&pixel_x, &pixel_y) && iEnableButtons) {
        if (!gameStarted) {
            if (pixel_x > START_BTN_X && pixel_x < (START_BTN_X + BUTTON_W) &&
                pixel_y > START_BTN_Y && pixel_y < (START_BTN_Y + BUTTON_H)) {
                
                iEnableButtons = 0;
                
                while(tft.getTouch(&pixel_x, &pixel_y)) {
                    delay(10);
                }
                
                playSound(1);
                delay(GAME_START_DELAY);
                
                tft.fillScreen(TFT_BLACK);
                initializeGame();
                shuffleBoard();
                gameStarted = true;
                
                while(tft.getTouch(&pixel_x, &pixel_y)) {
                    delay(10);
                }
                
                delay(250);
                iEnableButtons = 1;
            }
        } else {
            handleGameTouch();
            checkMenuButtons();
        }
    }
}

void changeBoardColor() {
    currentColorIndex = (currentColorIndex + 1) % 5;
    for (int y = 0; y < GRID_SIZE; y++) {
        for (int x = 0; x < GRID_SIZE; x++) {
            if (gameBoard[y][x] != 0) {
                int pixelX = GRID_START_X + x * (TILE_SIZE + TILE_SPACING);
                int pixelY = GRID_START_Y + y * (TILE_SIZE + TILE_SPACING);
                tft.fillRect(pixelX, pixelY, TILE_SIZE, TILE_SIZE, boardColors[currentColorIndex]);
                
                tft.setTextColor(TILE_TEXT_COLOR);
                tft.setTextSize(2);
                String number = String(gameBoard[y][x]);
                int textWidth = number.length() * 12;
                int textHeight = 14;
                int textX = pixelX + (TILE_SIZE - textWidth) / 2;
                int textY = pixelY + (TILE_SIZE - textHeight) / 2;
                tft.setCursor(textX, textY);
                tft.print(number);
            }
        }
    }
}

void playSound(int type) {
    if (!soundEnabled) return;
    
    switch(type) {
        case 0: // Tile move sound
            tone(SOUND_PIN, 200, 50);  // Short 200Hz beep
            break;
            
        case 1: // Game start sound
            soundEnabled = false;
            tone(SOUND_PIN, 400, 200);
            delay(250);
            tone(SOUND_PIN, 600, 200);
            delay(250);
            tone(SOUND_PIN, 800, 400);
            delay(500);
            soundEnabled = true;
            break;
            
        case 2: // Win sound
            soundEnabled = false;
            tone(SOUND_PIN, 800, 200);
            delay(200);
            tone(SOUND_PIN, 1000, 200);
            delay(200);
            tone(SOUND_PIN, 1200, 400);
            delay(500);
            soundEnabled = true;
            break;
    }
}

void updateMovesDisplay() {
    // Clear the previous number with MAROON background
    tft.fillRect(MENU_BTN_X + 1, MENU_BTN_Y1 + 35, MENU_BTN_W - 2, 20, TFT_MAROON);
    
    tft.setTextColor(TFT_WHITE);
    tft.setTextSize(1);
    
    String movesStr = String(moves);
    int textWidth = movesStr.length() * 6;
    
    tft.setCursor(MENU_BTN_X + (MENU_BTN_W - textWidth) / 2, MENU_BTN_Y1 + 35);
    tft.print(movesStr);
}

void showWelcomeScreen() {
    tft.fillScreen(TFT_BLACK);
    drawFrame(5, TFT_RED);
    
    tft.setTextColor(TFT_YELLOW);
    tft.setTextSize(4);
    tft.setCursor(130, 10);
    tft.print("15");
    tft.setCursor(75, 70);
    tft.print("PUZZLE");
    
    tft.fillRect(START_BTN_X, START_BTN_Y, BUTTON_W, BUTTON_H, TFT_RED);
    tft.setTextColor(TFT_WHITE);
    tft.setTextSize(2);
    tft.setCursor(START_BTN_X + 10, START_BTN_Y + 10);
    tft.print("START");
}

void drawFrame(int size, uint16_t color) {
    for (int i = 0; i < size; i++) {
        tft.drawRect(i, i, 320-i*2, 240-i*2, color);
    }
}

void initializeGame() {
    tft.fillScreen(TFT_BLACK);
    drawFrame(5, TFT_RED);
    
    emptyTileX = GRID_SIZE - 1;
    emptyTileY = GRID_SIZE - 1;
    moves = 0;
    currentColorIndex = 0;  // Reset color to first color
    
    int value = 1;
    for (int y = 0; y < GRID_SIZE; y++) {
        for (int x = 0; x < GRID_SIZE; x++) {
            if (x == GRID_SIZE-1 && y == GRID_SIZE-1) {
                gameBoard[y][x] = 0;
            } else {
                gameBoard[y][x] = value++;
            }
        }
    }
    
    drawBoard();
    drawMenuButtons();
}

void drawBoard() {
    for (int y = 0; y < GRID_SIZE; y++) {
        for (int x = 0; x < GRID_SIZE; x++) {
            drawTile(x, y);
        }
    }
}

void drawTile(int x, int y) {
    int pixelX = GRID_START_X + x * (TILE_SIZE + TILE_SPACING);
    int pixelY = GRID_START_Y + y * (TILE_SIZE + TILE_SPACING);
    
    if (gameBoard[y][x] == 0) {
        tft.fillRect(pixelX, pixelY, TILE_SIZE, TILE_SIZE, EMPTY_COLOR);
    } else {
        tft.fillRect(pixelX, pixelY, TILE_SIZE, TILE_SIZE, boardColors[currentColorIndex]);
        tft.setTextColor(TILE_TEXT_COLOR);
        tft.setTextSize(2);
        
        String number = String(gameBoard[y][x]);
        int textWidth = number.length() * 12;
        int textHeight = 14;
        
        int textX = pixelX + (TILE_SIZE - textWidth) / 2;
        int textY = pixelY + (TILE_SIZE - textHeight) / 2;
        
        tft.setCursor(textX, textY);
        tft.print(number);
    }
}

void drawMenuButtons() {
    // Draw three menu buttons on the right
    for(int i = 0; i < 3; i++) {
        int y_pos;
        switch(i) {
            case 0: y_pos = MENU_BTN_Y1; break;
            case 1: y_pos = MENU_BTN_Y2; break;
            case 2: y_pos = MENU_BTN_Y3; break;
        }
        
        // Set different colors for each button
        uint16_t buttonColor;
        if (i == 0) buttonColor = TFT_MAROON;      // Moves button
        else if (i == 1) buttonColor = TFT_BLUE;   // Color change button
        else buttonColor = TFT_DARKGREEN;          // New game button
        
        tft.fillRect(MENU_BTN_X, y_pos, MENU_BTN_W, MENU_BTN_H, buttonColor);
        tft.drawRect(MENU_BTN_X, y_pos, MENU_BTN_W, MENU_BTN_H, TFT_WHITE);
        
        if (i == 0) {  // Top button - Moves counter
            tft.setTextColor(TFT_WHITE);
            tft.setTextSize(1);
            tft.setCursor(MENU_BTN_X + (MENU_BTN_W - 30) / 2, y_pos + 15);
            tft.print("MOVES");
            updateMovesDisplay();
        }
        else if (i == 1) {  // Middle button - Color changer
            tft.setTextColor(TFT_WHITE);
            tft.setTextSize(1);
            tft.setCursor(MENU_BTN_X + (MENU_BTN_W - 30) / 2, y_pos + 15);
            tft.print("COLOR");
            tft.setCursor(MENU_BTN_X + (MENU_BTN_W - 36) / 2, y_pos + 35);
            tft.print("CHANGE");
        }
        else if (i == 2) {  // Bottom button - NEW GAME
            tft.setTextColor(TFT_WHITE);
            tft.setTextSize(1);
            tft.setCursor(MENU_BTN_X + (MENU_BTN_W - 18) / 2, y_pos + 20);
            tft.print("NEW");
            tft.setCursor(MENU_BTN_X + (MENU_BTN_W - 24) / 2, y_pos + 40);
            tft.print("GAME");
        }
    }
}

void handleGameTouch() {
    int tileX = (pixel_x - GRID_START_X) / (TILE_SIZE + TILE_SPACING);
    int tileY = (pixel_y - GRID_START_Y) / (TILE_SIZE + TILE_SPACING);
    
    if (tileX >= 0 && tileX < GRID_SIZE && tileY >= 0 && tileY < GRID_SIZE) {
        if (isValidMove(tileX, tileY)) {
            moveTile(tileX, tileY);
            moves++;
            updateMovesDisplay();
            
            if (checkWin()) {
                gameWon();
            }
        }
    }
}

void checkMenuButtons() {
    unsigned long currentTime = millis();
    
    if (currentTime - lastButtonPress < BUTTON_DEBOUNCE_TIME) {
        return;
    }
    
    if (pixel_x >= MENU_BTN_X && pixel_x < (MENU_BTN_X + MENU_BTN_W)) {
        if (pixel_y >= MENU_BTN_Y1 && pixel_y < (MENU_BTN_Y1 + MENU_BTN_H)) {
            playSound(0);
            lastButtonPress = currentTime;
            
            while(tft.getTouch(&pixel_x, &pixel_y)) {
                delay(10);
            }
        }
        else if (pixel_y >= MENU_BTN_Y2 && pixel_y < (MENU_BTN_Y2 + MENU_BTN_H)) {
            playSound(0);
            changeBoardColor();
            lastButtonPress = currentTime;
            
            while(tft.getTouch(&pixel_x, &pixel_y)) {
                delay(10);
            }
        }
        else if (pixel_y >= MENU_BTN_Y3 && pixel_y < (MENU_BTN_Y3 + MENU_BTN_H)) {
            iEnableButtons = 0;
            
            while(tft.getTouch(&pixel_x, &pixel_y)) {
                delay(10);
            }
            
            playSound(1);
            delay(GAME_START_DELAY);
            
            initializeGame();
            shuffleBoard();
            gameStarted = true;
            
            while(tft.getTouch(&pixel_x, &pixel_y)) {
                delay(10);
            }
            
            delay(250);
            iEnableButtons = 1;
            lastButtonPress = currentTime;
        }
    }
}

bool isValidMove(int x, int y) {
    return (
        (abs(x - emptyTileX) == 1 && y == emptyTileY) ||
        (abs(y - emptyTileY) == 1 && x == emptyTileX)
    );
}

void moveTile(int x, int y) {
    gameBoard[emptyTileY][emptyTileX] = gameBoard[y][x];
    gameBoard[y][x] = 0;
    drawTile(emptyTileX, emptyTileY);
    drawTile(x, y);
    emptyTileX = x;
    emptyTileY = y;
    playSound(0);
}

void silentMoveTile(int x, int y) {
    gameBoard[emptyTileY][emptyTileX] = gameBoard[y][x];
    gameBoard[y][x] = 0;
    emptyTileX = x;
    emptyTileY = y;
}

void shuffleBoard() {
    iEnableButtons = 0;
    soundEnabled = false;
    
    randomSeed(analogRead(34));
    for (int i = 0; i < 200; i++) {
        int direction = random(4);
        int newX = emptyTileX;
        int newY = emptyTileY;
        
        switch (direction) {
            case 0: newY--; break;
            case 1: newY++; break;
            case 2: newX--; break;
            case 3: newX++; break;
        }
        
        if (newX >= 0 && newX < GRID_SIZE && newY >= 0 && newY < GRID_SIZE) {
            silentMoveTile(newX, newY);
        }
    }
    
    drawBoard();
    
    delay(250);
    soundEnabled = true;
    iEnableButtons = 1;
}

bool checkWin() {
    int value = 1;
    for (int y = 0; y < GRID_SIZE; y++) {
        for (int x = 0; x < GRID_SIZE; x++) {
            if (y == GRID_SIZE-1 && x == GRID_SIZE-1) {
                if (gameBoard[y][x] != 0) return false;
            } else {
                if (gameBoard[y][x] != value++) return false;
            }
        }
    }
    return true;
}

void gameWon() {
    tft.fillScreen(TFT_BLACK);
    drawFrame(10, TFT_GREEN);
    
    tft.setTextColor(TFT_YELLOW);
    tft.setTextSize(2);
    tft.setCursor(60, 60);
    tft.print("PUZZLE SOLVED!");
    
    tft.setTextSize(2);
    tft.setCursor(80, 120);
    tft.print("Moves: ");
    tft.print(moves);
    
    tft.setTextSize(1);
    tft.setCursor(85, 180);
    tft.print("Touch screen to continue");
    
    playSound(2);
    
    while(tft.getTouch(&pixel_x, &pixel_y)) {
        delay(10);
    }
    
    while(!tft.getTouch(&pixel_x, &pixel_y)) {
        delay(10);
    }
    
    gameStarted = false;
    moves = 0;
    showWelcomeScreen();
}

Credits

Mirko Pavleski
188 projects • 1451 followers

Comments