From 73981c95c5bd856b4ff279611c6a48e120e7b4de Mon Sep 17 00:00:00 2001 From: Seppe De Loore Date: Fri, 6 Mar 2026 22:14:25 +0100 Subject: [PATCH] [refactor] Progressive difficulty, blunder logic and documentation. --- README.md | 129 ++++++++++------------- src/main.cpp | 293 ++++++++++++++++++++++++++++++++------------------- 2 files changed, 242 insertions(+), 180 deletions(-) diff --git a/README.md b/README.md index 0d81643..dd2959e 100644 --- a/README.md +++ b/README.md @@ -1,95 +1,82 @@ -# Connect Four: ESP32-C3 LED Edition +# đŸ•šī¸ Connect 4 AI: Master Edition -A hardware-based Connect Four game featuring an 8x8 NeoPixel matrix, a strategic Minimax AI, and a dynamic "Attract Mode" for public display. - -## How the Program Works - -The program is built as a **Finite State Machine (FSM)**. -It manages the game flow by transitioning between distinct states based on user input, game outcomes, or inactivity timers. - -### 1. Game States - -- **Menu**: Displays stylized Roman numerals (**I** or **II**) to select between Single Player or Two Player modes. -- **Game Play**: The main loop handles the real-time gravity of falling discs, encoder tracking for column selection, and the hand-off between the human and the AI. -- **Game Over**: Triggered when a win or draw is detected. It "locks" the board, dims the background discs to 15% intensity, and flashes the winning line. -- **Demo Mode**: Triggered after 60 seconds of inactivity. The AI plays against itself to act as a visual "attract mode." - -### 2. Win Detection Logic - -To ensure 100% accuracy, the program performs a synchronous, multi-directional scan of the 7x6 grid after every single move. -It checks for four matching non-zero values in the following patterns: - -- **Horizontal**: `[column] [row]` to `[column + 3] [row]` -- **Vertical**: `[column] [row]` to `[column] [row + 3]` -- **Diagonal Up**: `[column] [row]` to `[column + 3] [row + 3]` -- **Diagonal Down**: `[column] [row]` to `[column + 3] [row - 3]` +A high-performance Connect 4 implementation for ESP32-C3 and 8x8 WS2812B matrices. Features dynamic difficulty scaling, "humanized" AI movement, and a mobile-friendly web administration portal. --- -## The AI: Strategic Minimax +## 🛠 Hardware Configuration -The computer opponent uses the **Minimax Algorithm**, a classic artificial intelligence method for zero-sum games. +### 🔌 Pin Mapping (Lolin C3 Mini) -### 1. Look-Ahead (Depth Search) +| Component | ESP32-C3 Pin | Function | +| :------------------- | :----------- | :--------------- | +| **NeoPixel Matrix** | `GPIO 4` | Data Input (DIN) | +| **Rotary Encoder A** | `GPIO 0` | Directional CLK | +| **Rotary Encoder B** | `GPIO 1` | Directional DT | +| **Encoder Button** | `GPIO 2` | Selection (SW) | -The AI does not just look at the current board; it simulates the game **6 to 8 moves into the future**. -It explores a "tree" of possibilities: _"If I play here, and the player plays there, then I can play here..."_ +### 📐 Physical Dimensions -### 2. Alpha-Beta Pruning +Designed for standard 8x8 matrix modules (approx. 65mm x 67mm). -Because searching millions of possibilities would be too slow for a microcontroller, we use **Alpha-Beta Pruning**. -This allows the AI to "prune" (ignore) branches of the game tree that are mathematically -guaranteed to be worse than moves it has already found, significantly speeding up the calculation. - -### 3. Immediate Threat Reaction - -To prevent the AI from being "distracted" by deep strategies while missing a simple win or loss, -we implemented a high-priority **Reaction Scanner**: - -- **Kill Move**: If the AI can win in exactly one move, it takes it immediately. -- **Block Move**: If the player is one move away from winning (3-in-a-row), the AI identifies the threat and blocks it regardless of the Minimax score. - -### 4. Controlled Randomness (Demo Mode) - -To keep the Demo Mode interesting for spectators, the AI has a 25% chance to ignore the "perfect" move and pick a random column. -This ensures that every demo game is unique and not a repetitive loop of the same strategy. +- **Top Row (0):** Interaction and AI decision visualization. +- **Game Board:** Standard $7 \times 6$ grid. +- **UI Borders:** Fixed blue frame for visibility. --- -## Technical Specifications +## 🧠 Advanced AI Features -### Hardware Pins (Lolin C3 Mini) +### 1. Progressive Difficulty (Evolution Mode) -| Component | Pin | Function | -| :---------- | :-- | :--------------------------------------- | -| **LED_PIN** | 4 | WS2812B NeoPixel Data | -| **ENC_A** | 0 | Rotary Encoder Phase A | -| **ENC_B** | 1 | Rotary Encoder Phase B | -| **ENC_SW** | 2 | Switch (Includes 50ms Software Debounce) | +The AI search depth (Ply) increases as the board fills. This ensures the AI is fast in the opening and lethal in the endgame. -### NeoPixel Grid Layout +- **Formula:** $DynamicPly = BasePly + \lfloor \frac{DiscsOnBoard}{7} \rfloor$ +- **Benefit:** High-level tactical precision exactly when the game becomes critical. -The 8x8 matrix is mapped as follows: +### 2. Strategic Blunder Injection -- **Play Area**: 7 columns (0-6) by 6 rows (0-5). -- **Boundaries**: Row 1 and Column 7 are lit in **Blue** to mark the board limits. -- **Indicators**: The top-right pixel (7,0) pulses in the computer's color while it is "thinking." -- **Glowing Frame**: During Demo mode, the blue borders pulse with a white "glow" effect using a `beat8` sine wave to indicate autonomous play. +To avoid endless stalemate draws between high-level AIs, a "Blunder" logic is used. + +- **Demo Mode:** Always active; 20% chance to make a suboptimal move. +- **Player Mode:** Toggleable via Web Portal to make the AI more "human." + +### 3. Alpha-Beta Pruning & Column Ordering + +The engine evaluates the center column first. This triggers pruning earlier in the search tree, skipping millions of unnecessary calculations and keeping the ESP32-C3 responsive. --- -## Controls & Interaction +## 📖 Code Architecture Details -- **Rotate Encoder**: Move the cursor (top row) to select a column. -- **Press Encoder Button**: Drop a disc. -- **Full Column Warning**: If you attempt to play in a full column, the selection disc will blink rapidly, and the move will be ignored. -- **Reset**: After a game ends, press the button once to return to the Menu. +### 🔄 State Machine -## Build Flags (platformio.ini) +The software cycles through states: -Tweak the game performance without changing the source code: +- **MENU:** Select mode using the rotary encoder. +- **PLAYING:** Manages player turns and the gravity-acceleration drop animation. +- **DEMO:** Auto-starts after inactivity. Randomizes Ply (3-6) and enforces blunders to ensure definitive game results. -- `IDLE_TIMEOUT`: Time (ms) before Demo Mode starts. -- `DEMO_RESET_PAUSE`: Delay (ms) between games in Demo Mode. -- `DEBOUNCE_DELAY`: Sensitivity of the encoder button. -- `BRIGHTNESS`: Global brightness of the NeoPixels. +### 🎨 Rendering & Mapping + +The `getIdx(x, y)` function maps the 2D game board to the 1D NeoPixel array. The `updateThinkingVisuals()` function provides real-time feedback of the AI's internal search process by moving a pulsing disc across the top row. + +--- + +## 🌐 Web Admin Portal + +Connect to the **"Connect4-Config"** Access Point to adjust: + +- **Base Ply:** Minimum search depth. +- **Brightness:** Global LED intensity. +- **Idle Timeout:** Inactivity period before Demo Mode. +- **Toggles:** Enable/Disable Blunders and Evolution Mode. + +--- + +## 🛠 Setup & Installation + +1. Install **PlatformIO**. +2. Add dependencies: `FastLED`, `Encoder`. +3. Set your WiFi Password in `platformio.ini`: `-D WIFI_PASSWORD=\"your_pass\"`. +4. Upload to ESP32-C3. diff --git a/src/main.cpp b/src/main.cpp index 97ebe83..d8293c2 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -9,6 +9,7 @@ const int COLS = 7; const int ROWS = 6; +// --- Configuration & Globals --- CRGB leds[NUM_LEDS]; Encoder myEnc(ENC_A, ENC_B); WebServer server(80); @@ -33,41 +34,36 @@ long oldEncPos = -999; uint32_t lastActivityTime = 0; uint32_t demoResetTimer = 0; bool isDemoOver = false; +uint8_t demoPly = 4; -// Web-Configurable Parameters (Stored in Flash) +// Configurable Parameters uint8_t current_look_ahead; uint8_t current_brightness; uint32_t current_idle_timeout_ms; +bool blunder_enabled = false; +bool progressive_difficulty = false; -// Thinking Animation Helpers uint8_t aiBrightness = 0; bool aiFadeUp = true; -// --- Helper Functions --- -int getIdx(int x, int y) { return (y * 8) + x; } +// --- Function Prototypes --- +int getIdx(int x, int y); +void drawStaticUI(); +void renderBoard(); +int getFirstEmptyRow(int col); +bool isBoardFull(); +bool scanBoard(int8_t p); +void updateThinkingVisuals(int8_t p, int8_t col); +void animateDrop(int col, int player); +void moveDiscToCol(int startCol, int targetCol, int player, int speed); +int minimax(int depth, int alpha, int beta, bool isMax, int8_t aiP, int8_t huP, int8_t rootCol); +void performAiMove(int8_t aiP); +void showMenu(); +int getDynamicPly(); -void updateThinkingLED(int8_t p) -{ - static uint32_t lastCycle = 0; - if (millis() - lastCycle < 20) - return; - lastCycle = millis(); - if (aiFadeUp) - { - aiBrightness += 15; - if (aiBrightness >= 240) - aiFadeUp = false; - } - else - { - aiBrightness -= 15; - if (aiBrightness <= 15) - aiFadeUp = true; - } - CRGB compColor = (p == 1) ? CRGB::Yellow : CRGB::Red; - leds[getIdx(7, 0)] = compColor.nscale8(aiBrightness); - FastLED.show(); -} +// --- Utility & Rendering --- + +int getIdx(int x, int y) { return (y * 8) + x; } void drawStaticUI() { @@ -109,6 +105,85 @@ int getFirstEmptyRow(int col) return -1; } +int getDynamicPly() +{ + if (!progressive_difficulty && gameState != DEMO) + return current_look_ahead; + int count = 0; + for (int c = 0; c < COLS; c++) + for (int r = 0; r < ROWS; r++) + if (board[c][r] != 0) + count++; + int evolution = count / 7; + return constrain(current_look_ahead + evolution, 1, 10); +} + +// --- Visuals & Animations --- + +void updateThinkingVisuals(int8_t p, int8_t col) +{ + static uint32_t lastCycle = 0; + if (millis() - lastCycle < 25) + return; + lastCycle = millis(); + if (aiFadeUp) + { + aiBrightness += 15; + if (aiBrightness >= 240) + aiFadeUp = false; + } + else + { + aiBrightness -= 15; + if (aiBrightness <= 15) + aiFadeUp = true; + } + for (int x = 0; x < COLS; x++) + leds[getIdx(x, 0)] = CRGB::Black; + CRGB aiColor = (p == 1) ? CRGB::Yellow : CRGB::Red; + leds[getIdx(col, 0)] = aiColor.nscale8(aiBrightness); + FastLED.show(); + activeCol = col; +} + +void animateDrop(int col, int player) +{ + int targetRow = getFirstEmptyRow(col); + if (targetRow == -1) + return; + int currentDelay = 80; + for (int r = 5; r >= targetRow; r--) + { + renderBoard(); + leds[getIdx(col, 7 - r)] = (player == 1) ? CRGB::Yellow : CRGB::Red; + FastLED.show(); + delay(currentDelay); + if (currentDelay > 20) + currentDelay -= 15; + } + board[col][targetRow] = player; + renderBoard(); + FastLED.show(); +} + +void moveDiscToCol(int startCol, int targetCol, int player, int speed) +{ + int current = startCol; + CRGB pColor = (player == 1) ? CRGB::Yellow : CRGB::Red; + while (current != targetCol) + { + leds[getIdx(current, 0)] = CRGB::Black; + current += (targetCol > current) ? 1 : -1; + renderBoard(); + leds[getIdx(current, 0)] = pColor; + FastLED.show(); + delay(speed); + } + activeCol = targetCol; +} + +// --- AI Engine --- + bool isBoardFull() { for (int c = 0; c < COLS; c++) @@ -150,21 +225,18 @@ bool scanBoard(int8_t p) return found; } -// --- AI Engine --- -int minimax(int depth, int alpha, int beta, bool isMax, int8_t aiP, int8_t huP) +int minimax(int depth, int alpha, int beta, bool isMax, int8_t aiP, int8_t huP, int8_t rootCol) { if (depth >= current_look_ahead - 1) - updateThinkingLED(aiP); + updateThinkingVisuals(aiP, rootCol); else yield(); - if (scanBoard(aiP)) return 1000 + depth; if (scanBoard(huP)) return -1000 - depth; if (depth == 0 || isBoardFull()) return 0; - int order[] = {3, 2, 4, 1, 5, 0, 6}; int best = isMax ? -2000 : 2000; for (int c : order) @@ -173,7 +245,7 @@ int minimax(int depth, int alpha, int beta, bool isMax, int8_t aiP, int8_t huP) if (r != -1) { board[c][r] = isMax ? aiP : huP; - int val = minimax(depth - 1, alpha, beta, !isMax, aiP, huP); + int val = minimax(depth - 1, alpha, beta, !isMax, aiP, huP, (depth == current_look_ahead ? c : rootCol)); board[c][r] = 0; if (isMax) { @@ -196,9 +268,12 @@ int minimax(int depth, int alpha, int beta, bool isMax, int8_t aiP, int8_t huP) void performAiMove(int8_t aiP) { - int8_t huP = (aiP == 1) ? 2 : 1; - aiBrightness = 0; - aiFadeUp = true; + int huP = (aiP == 1) ? 2 : 1; + int bestScore = -30000; + int bestCol = 3; + int originalPly = current_look_ahead; + current_look_ahead = (gameState == DEMO) ? demoPly : getDynamicPly(); + for (int c = 0; c < COLS; c++) { int r = getFirstEmptyRow(c); @@ -207,28 +282,28 @@ void performAiMove(int8_t aiP) board[c][r] = aiP; if (scanBoard(aiP)) { - leds[getIdx(7, 0)] = CRGB::Black; - return; + board[c][r] = 0; + bestCol = c; + goto finalize; } board[c][r] = huP; if (current_look_ahead >= 2 && scanBoard(huP)) { - board[c][r] = aiP; - leds[getIdx(7, 0)] = CRGB::Black; - return; + board[c][r] = 0; + bestCol = c; + goto finalize; } board[c][r] = 0; } } - int bestScore = -30000; - int bestCol = 3; + for (int c : {3, 2, 4, 1, 5, 0, 6}) { int r = getFirstEmptyRow(c); if (r != -1) { board[c][r] = aiP; - int score = minimax(current_look_ahead, -30000, 30000, false, aiP, huP); + int score = minimax(current_look_ahead, -30000, 30000, false, aiP, huP, c); board[c][r] = 0; if (score > bestScore) { @@ -237,11 +312,63 @@ void performAiMove(int8_t aiP) } } } - board[bestCol][getFirstEmptyRow(bestCol)] = aiP; - leds[getIdx(7, 0)] = CRGB::Black; + + if ((gameState == DEMO || blunder_enabled) && random(100) < 20) + { + int rCol = random(0, 7); + if (getFirstEmptyRow(rCol) != -1) + bestCol = rCol; + } + +finalize: + current_look_ahead = originalPly; + moveDiscToCol(activeCol, bestCol, aiP, 100); + delay(450); + animateDrop(bestCol, aiP); +} + +// --- Web Portal & Setup --- + +void handleRoot() +{ + String html = ""; + html += "

Connect 4 Admin

"; + html += "Base AI Ply (1-10):"; + html += "Brightness (5-255):"; + html += "Idle Timeout (Sec):"; + html += "Enable Blunders:
"; + html += "Evolution Mode:

"; + html += "
"; + server.send(200, "text/html", html); +} + +void handleSave() +{ + if (server.hasArg("ply")) + { + current_look_ahead = server.arg("ply").toInt(); + prefs.putUChar("ply", current_look_ahead); + } + if (server.hasArg("br")) + { + current_brightness = server.arg("br").toInt(); + FastLED.setBrightness(current_brightness); + prefs.putUChar("br", current_brightness); + } + if (server.hasArg("idle")) + { + uint32_t s = server.arg("idle").toInt(); + current_idle_timeout_ms = s * 1000; + prefs.putUInt("idle", s); + } + blunder_enabled = server.hasArg("blunder"); + prefs.putBool("blunder", blunder_enabled); + progressive_difficulty = server.hasArg("evolve"); + prefs.putBool("evolve", progressive_difficulty); + server.sendHeader("Location", "/"); + server.send(303); } -// --- Menu UI with Restored Serifs --- void showMenu() { isDemoOver = false; @@ -279,42 +406,6 @@ void showMenu() FastLED.show(); } -// --- Web Portal --- -void handleRoot() -{ - String html = ""; - html += ""; - html += "

Connect 4 Admin

"; - html += "AI Ply (1-10):"; - html += "Brightness (5-255):"; - html += "Idle Timeout (Sec):"; - html += "
"; - server.send(200, "text/html", html); -} - -void handleSave() -{ - if (server.hasArg("ply")) - { - current_look_ahead = server.arg("ply").toInt(); - prefs.putUChar("ply", current_look_ahead); - } - if (server.hasArg("br")) - { - current_brightness = server.arg("br").toInt(); - FastLED.setBrightness(current_brightness); - prefs.putUChar("br", current_brightness); - } - if (server.hasArg("idle")) - { - uint32_t s = server.arg("idle").toInt(); - current_idle_timeout_ms = s * 1000; - prefs.putUInt("idle", s); - } - server.sendHeader("Location", "/"); - server.send(303); -} - void setup() { Serial.begin(115200); @@ -322,29 +413,20 @@ void setup() current_look_ahead = prefs.getUChar("ply", 8); current_brightness = prefs.getUChar("br", 25); current_idle_timeout_ms = prefs.getUInt("idle", 60) * 1000; + blunder_enabled = prefs.getBool("blunder", false); + progressive_difficulty = prefs.getBool("evolve", false); FastLED.addLeds(leds, NUM_LEDS); FastLED.setBrightness(current_brightness); pinMode(ENC_SW, INPUT_PULLUP); - WiFi.disconnect(true); // Clear old settings - WiFi.mode(WIFI_AP); // Force Access Point mode - delay(100); // Give the radio a moment to reset - - // SSID, Password (MUST be 8+ chars), Channel, Hidden (0=No), Max Clients - if (WiFi.softAP("Connect4-Config", WIFI_PASSWORD, 1, 0, 4)) - { - Serial.println("WPA2 AP Started Successfully"); - } - else - { - Serial.println("AP Failed - Check if WIFI_PASSWORD is at least 8 characters!"); - } - + WiFi.disconnect(true); + WiFi.mode(WIFI_AP); + delay(100); + WiFi.softAP("Connect4-Config", WIFI_PASSWORD); server.on("/", handleRoot); server.on("/save", HTTP_POST, handleSave); server.begin(); - lastActivityTime = millis(); showMenu(); } @@ -355,7 +437,6 @@ void loop() long newPos = myEnc.read() / SENSITIVITY; bool pressed = (digitalRead(ENC_SW) == LOW); - // Escape Demo / Interrupt if (newPos != oldEncPos || (pressed && (millis() - lastActivityTime > 500))) { if (gameState == DEMO || isDemoOver) @@ -366,7 +447,7 @@ void loop() FastLED.show(); delay(30); } - delay(2000); + delay(1000); gameState = MENU; memset(board, 0, sizeof(board)); showMenu(); @@ -405,6 +486,7 @@ void loop() gameState = DEMO; memset(board, 0, sizeof(board)); currentPlayer = 1; + demoPly = random(3, 7); } } else if (gameState == PLAYING) @@ -422,9 +504,7 @@ void loop() int row = getFirstEmptyRow(activeCol); if (row != -1) { - board[activeCol][row] = currentPlayer; - renderBoard(); - FastLED.show(); + animateDrop(activeCol, currentPlayer); if (scanBoard(currentPlayer)) gameState = FINISHED_WIN; else if (isBoardFull()) @@ -435,8 +515,6 @@ void loop() { int8_t aiP = (menuMode == 0) ? 2 : 1; performAiMove(aiP); - renderBoard(); - FastLED.show(); if (scanBoard(aiP)) { currentPlayer = aiP; @@ -456,7 +534,6 @@ void loop() } else if (gameState == DEMO) { - // No idle timeout check here to prevent premature restarts renderBoard(); FastLED.show(); delay(600); @@ -480,7 +557,6 @@ void loop() } else { - // Monitor for Idle in Win screen to return to Demo if (!isDemoOver && (millis() - lastActivityTime > current_idle_timeout_ms)) { memset(board, 0, sizeof(board)); @@ -488,7 +564,6 @@ void loop() currentPlayer = 1; return; } - static uint32_t lastFlash = 0; static bool toggle = true; if (millis() - lastFlash > 300) @@ -515,12 +590,12 @@ void loop() } FastLED.show(); } - // Restart Demo loop if it was a demo game - if (isDemoOver && (millis() - demoResetTimer > 30000)) + if (isDemoOver && (millis() - demoResetTimer > 15000)) { memset(board, 0, sizeof(board)); gameState = DEMO; isDemoOver = false; + demoPly = random(3, 7); } if (pressed) {