Compare commits

...

3 Commits

Author SHA1 Message Date
seppedl 3257d40722 [update] Background information and Dutch translation with blunder mode section.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 16:03:48 +01:00
seppedl f9d100f918 [update] README for blunder mode, WIFI_SSID, and DEMO_RESET_PAUSE build flags.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 16:03:46 +01:00
seppedl 0fc20da274 [refactor] Replace hardcoded values with build flags and cleanup duplicates.
- Add #ifndef guards for pin defines duplicated between platformio.ini and main.cpp
- Use DEFAULT_LOOK_AHEAD, DEFAULT_BRIGHTNESS, DEFAULT_IDLE_TIMEOUT, DEMO_RESET_PAUSE
  build flags instead of hardcoded magic numbers
- Add WIFI_SSID build flag for configurable access point name
- Remove unused build flags (BRIGHTNESS, IDLE_TIMEOUT, DEBOUNCE_DELAY)
- Remove progressive difficulty / evolution feature (getDynamicPly)
- Replace goto with structured control flow in performAiMove
- Deduplicate checkGameEnd win/draw branches
- Implement blunder mode: configurable chance (%) to pick a random column,
  preserving instant win/block detection

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 16:03:41 +01:00
5 changed files with 114 additions and 70 deletions
+9 -1
View File
@@ -131,7 +131,15 @@ De sterkere speler kan zo winnende zetten vinden die de zwakkere mist. Wie sterk
---
## 7. Snelle Bediening
## 7. Blunder-modus
Normaal speelt de AI altijd de beste zet die hij kan vinden. Maar dat kan frustrerend zijn voor jongere of minder ervaren spelers die nooit winnen. De **blunder-modus** geeft de AI een instelbare kans (bijvoorbeeld 20%) om een willekeurige zet te doen in plaats van diep na te denken. Als er een blunder gebeurt, slaat de AI zijn slimme analyse over en laat hij een schijfje in een willekeurige open kolom vallen. De rest van de tijd speelt hij gewoon op volle kracht — maar af en toe maakt hij een domme fout die een oplettende speler kan afstraffen.
Blunders gaan nooit boven een directe winst of blokkade. Als de AI nu kan winnen, of als de tegenstander op het punt staat te winnen, maakt de AI altijd de juiste zet. Blunders vervangen alleen de diepe zoektocht op beurten waar er geen directe dreiging is.
---
## 8. Snelle Bediening
De ESP32-C3 heeft maar één kern. Als de AI nadenkt, kan hij de bediening een paar seconden blokkeren.
Twee trucs zorgen ervoor dat het spel soepel blijft:
+7 -1
View File
@@ -112,7 +112,13 @@ This three-phase approach makes the AI both fast (instant reactions to obvious m
In demo mode, two AI players play against each other. To make the games interesting (rather than always ending in a draw), each player is randomly assigned a different search depth. One player might look 5 moves ahead while the other only looks 3 moves ahead. The stronger player can find winning setups that the weaker one misses, leading to exciting games with real winners. Who gets the advantage is randomized each game.
## 7. Responsive Controls
## 7. Blunder Mode
Normally, the AI always plays the best move it can find. But that can be frustrating for younger or casual players who never get to win. **Blunder mode** gives the AI a configurable chance (for example 20%) to make a random move instead of running the deep minimax search. When a blunder happens, the AI simply drops a disc in a random open column. It still plays normally the rest of the time, so the game feels real - but every now and then the AI makes a silly mistake that a sharp player can punish.
Blunders never override an instant win or block. If the AI can win right now, or if the opponent is about to win, the AI always makes the correct move. Blunders only replace the deep search on turns where there is no immediate threat.
## 8. Responsive Controls
The ESP32-C3 is a single-core processor. When the AI is thinking, it could block all input for several seconds. Two techniques keep the game responsive:
+5 -4
View File
@@ -53,7 +53,7 @@ When idle (no input for the configured timeout), the board enters demo mode wher
The ESP32 creates a WiFi access point:
- **Network:** `Connect4-Config`
- **Network:** Configured via `WIFI_SSID` build flag (default: `Connect4`)
- **Password:** Configured via `WIFI_PASSWORD` build flag (default: `youlose4`)
- **Admin page:** Connect to the network and open `http://192.168.4.1`
@@ -64,8 +64,7 @@ The ESP32 creates a WiFi access point:
| **Base AI Ply** | Search depth for the AI (1-10). Higher = stronger. |
| **Brightness** | LED brightness (0-255). |
| **Idle Timeout** | Seconds of inactivity before demo mode starts. |
| **Blunders** | Reserved for future use. |
| **Evolution** | Progressive difficulty: AI gets stronger as game goes on.|
| **Blunders** | AI randomly picks a bad move at the configured chance %. |
Settings are saved to flash (NVS) and persist across reboots.
@@ -100,7 +99,7 @@ pio device monitor
All configurable parameters are defined as `-D` flags in `platformio.ini`:
| Flag | Default | Description |
| :--------------------- | :------ | :--------------------------------------------- |
| :--------------------- | :--------- | :------------------------------------------------- |
| `LED_PIN` | `4` | GPIO pin for NeoPixel data line |
| `ENC_A` | `0` | GPIO pin for encoder CLK |
| `ENC_B` | `1` | GPIO pin for encoder DT |
@@ -110,7 +109,9 @@ All configurable parameters are defined as `-D` flags in `platformio.ini`:
| `DEFAULT_LOOK_AHEAD` | `8` | Default AI search depth (plies) |
| `DEFAULT_BRIGHTNESS` | `25` | Default LED brightness (0-255) |
| `DEFAULT_IDLE_TIMEOUT` | `45` | Seconds before demo mode activates |
| `DEMO_RESET_PAUSE` | `30000` | Milliseconds before finished game enters demo |
| `MAX_GAME_LOG` | `5` | Number of games stored in the game log |
| `WIFI_SSID` | `Connect4` | SSID for the WiFi access point |
| `WIFI_PASSWORD` | `youlose4` | Password for the WiFi access point |
## Project Structure
+1 -3
View File
@@ -12,14 +12,12 @@ build_flags =
-D ENC_SW=2
-D SENSITIVITY=4
-D SHOW_BORDER=0
-D BRIGHTNESS=25
-D IDLE_TIMEOUT=45000
-D DEMO_RESET_PAUSE=20000
-D DEBOUNCE_DELAY=50
-D DEFAULT_LOOK_AHEAD=8
-D DEFAULT_BRIGHTNESS=25
-D DEFAULT_IDLE_TIMEOUT=45
-D MAX_GAME_LOG=100
-D WIFI_SSID=\"Connect4\"
-D WIFI_PASSWORD=\"youlose4\"
lib_deps =
fastled/FastLED @ 3.9.12
+70 -39
View File
@@ -13,16 +13,48 @@
#define SENSITIVITY 4
#endif
#ifndef LED_PIN
#define LED_PIN 4
#endif
#ifndef ENC_A
#define ENC_A 0
#endif
#ifndef ENC_B
#define ENC_B 1
#endif
#ifndef ENC_SW
#define ENC_SW 2
#endif
#define NUM_LEDS 64
#ifndef MAX_GAME_LOG
#define MAX_GAME_LOG 5
#endif
#ifndef DEFAULT_LOOK_AHEAD
#define DEFAULT_LOOK_AHEAD 8
#endif
#ifndef DEFAULT_BRIGHTNESS
#define DEFAULT_BRIGHTNESS 25
#endif
#ifndef DEFAULT_IDLE_TIMEOUT
#define DEFAULT_IDLE_TIMEOUT 60
#endif
#ifndef DEMO_RESET_PAUSE
#define DEMO_RESET_PAUSE 30000
#endif
#ifndef WIFI_SSID
#define WIFI_SSID "Connect4"
#endif
const int COLS = 7;
const int ROWS = 6;
const int colOrder[] = {3, 2, 4, 1, 5, 0, 6};
@@ -49,11 +81,11 @@ uint8_t demoPly[2] = {4, 4};
bool abortAi = false;
bool lastButtonState = HIGH;
uint8_t currentLookAhead = 6;
uint8_t currentBrightness = 30;
uint32_t currentIdleTimeoutMs = 60000;
uint8_t currentLookAhead = DEFAULT_LOOK_AHEAD;
uint8_t currentBrightness = DEFAULT_BRIGHTNESS;
uint32_t currentIdleTimeoutMs = DEFAULT_IDLE_TIMEOUT * 1000;
bool blunderEnabled = false;
bool progressiveDifficulty = false;
uint8_t blunderChance = 20;
uint8_t aiBrightness = 0;
bool aiFadeUp = true;
@@ -84,7 +116,6 @@ void renderBoard();
void showMenu();
int getFirstEmptyRow(int col);
bool isBoardFull();
int getDynamicPly();
int8_t scanBoard();
bool checkGameEnd();
void updateThinkingVisuals(int8_t pColor, int8_t column);
@@ -217,13 +248,6 @@ bool isBoardFull() {
return true;
}
int getDynamicPly() {
if (!progressiveDifficulty && gameState != DEMO) return currentLookAhead;
int count = 0;
for (int c = 0; c < COLS; c++) for (int r = 0; r < ROWS; r++) if (board[c][r] != 0) count++;
return constrain(currentLookAhead + (count / 7), 1, 10);
}
int8_t scanBoard() {
memset(winMask, 0, sizeof(winMask));
auto check = [&](int c, int r, int dc, int dr) {
@@ -243,21 +267,14 @@ int8_t scanBoard() {
bool checkGameEnd() {
winnerPlayer = scanBoard();
if (winnerPlayer != 0) {
if (gameState != DEMO) logGame(winnerPlayer);
gameState = FINISHED_WIN;
bool won = winnerPlayer != 0;
bool draw = !won && isBoardFull();
if (!won && !draw) return false;
if (gameState != DEMO) logGame(won ? winnerPlayer : 0);
gameState = won ? FINISHED_WIN : FINISHED_DRAW;
demoResetTimer = millis();
lastActivityTime = millis();
return true;
}
if (isBoardFull()) {
if (gameState != DEMO) logGame(0);
gameState = FINISHED_DRAW;
demoResetTimer = millis();
lastActivityTime = millis();
return true;
}
return false;
}
// --- Animation ---
@@ -337,18 +354,31 @@ void performAiMove(int8_t aiP) {
int huP = (aiP == 1) ? 2 : 1;
int bestScore = -30000; int bestCol = 3;
int originalPly = currentLookAhead;
currentLookAhead = (gameState == DEMO) ? demoPly[aiP - 1] : getDynamicPly();
if (gameState == DEMO) currentLookAhead = demoPly[aiP - 1];
for (int c = 0; c < COLS; c++) {
// Phase 1: always take an instant win or block an opponent's win
bool found = false;
for (int c = 0; c < COLS && !found; c++) {
int r = getFirstEmptyRow(c);
if (r != -1) {
board[c][r] = aiP; if (scanBoard() == aiP) { board[c][r]=0; bestCol=c; goto finalizeMove; }
board[c][r] = huP; if (scanBoard() == huP) { board[c][r]=0; bestCol=c; goto finalizeMove; }
board[c][r] = aiP; if (scanBoard() == aiP) { board[c][r]=0; bestCol=c; found=true; break; }
board[c][r] = huP; if (scanBoard() == huP) { board[c][r]=0; bestCol=c; found=true; break; }
board[c][r] = 0;
}
}
// Phase 2: blunder — pick a random column instead of deep search
if (!found && blunderEnabled && gameState != DEMO && (random(100) < blunderChance)) {
int validCols[COLS], count = 0;
for (int c = 0; c < COLS; c++) if (getFirstEmptyRow(c) != -1) validCols[count++] = c;
bestCol = validCols[random(count)];
found = true;
}
// Phase 3: deep minimax search
if (!found) {
for (int c : colOrder) {
if (abortAi) goto finalizeMove;
if (abortAi) break;
int r = getFirstEmptyRow(c);
if (r != -1) {
board[c][r] = aiP;
@@ -357,7 +387,8 @@ void performAiMove(int8_t aiP) {
if (score > bestScore) { bestScore = score; bestCol = c; }
}
}
finalizeMove:
}
currentLookAhead = originalPly;
if (!abortAi) { moveDiscToCol(activeCol, bestCol, aiP, 80); if (!abortAi) { delay(100); animateDrop(bestCol, aiP); } }
}
@@ -377,8 +408,8 @@ void handleRoot() {
html += "Base AI Ply:<input type='number' name='ply' value='" + String(currentLookAhead) + "'>";
html += "Brightness:<input type='number' name='br' value='" + String(currentBrightness) + "'>";
html += "Idle Timeout (s):<input type='number' name='idle' value='" + String(currentIdleTimeoutMs / 1000) + "'>";
html += "Blunders: <input type='checkbox' name='blunder' " + String(blunderEnabled ? "checked" : "") + "><br>";
html += "Evolution: <input type='checkbox' name='evolve' " + String(progressiveDifficulty ? "checked" : "") + "><br><br>";
html += "Blunders: <input type='checkbox' name='blunder' " + String(blunderEnabled ? "checked" : "") + ">";
html += " Chance (%):<input type='number' name='blunderPct' min='1' max='100' value='" + String(blunderChance) + "'><br><br>";
html += "<input type='submit' value='Save Settings' style='background:#28a745;color:white;'>";
html += "</form></div>";
html += "<div class='card' style='margin-top:15px;text-align:left;'><h3 style='text-align:center;'>Game Log</h3>";
@@ -405,7 +436,7 @@ void handleSave() {
if (server.hasArg("br")) { currentBrightness = server.arg("br").toInt(); FastLED.setBrightness(currentBrightness); prefs.putUChar("br", currentBrightness); }
if (server.hasArg("idle")) { currentIdleTimeoutMs = server.arg("idle").toInt() * 1000; prefs.putUInt("idle", currentIdleTimeoutMs / 1000); }
blunderEnabled = server.hasArg("blunder"); prefs.putBool("blunder", blunderEnabled);
progressiveDifficulty = server.hasArg("evolve"); prefs.putBool("evolve", progressiveDifficulty);
if (server.hasArg("blunderPct")) { blunderChance = constrain(server.arg("blunderPct").toInt(), 1, 100); prefs.putUChar("blPct", blunderChance); }
server.sendHeader("Location", "/"); server.send(303);
}
@@ -485,7 +516,7 @@ void handleFinished() {
}
FastLED.show();
}
if (millis() - demoResetTimer > 30000) {
if (millis() - demoResetTimer > DEMO_RESET_PAUSE) {
resetBoard();
randomizeDemoPlies();
gameState = DEMO;
@@ -498,16 +529,16 @@ void handleFinished() {
void setup() {
prefs.begin("c4-game", false);
currentLookAhead = prefs.getUChar("ply", 8);
currentBrightness = prefs.getUChar("br", 25);
currentIdleTimeoutMs = prefs.getUInt("idle", 60) * 1000;
currentLookAhead = prefs.getUChar("ply", DEFAULT_LOOK_AHEAD);
currentBrightness = prefs.getUChar("br", DEFAULT_BRIGHTNESS);
currentIdleTimeoutMs = prefs.getUInt("idle", DEFAULT_IDLE_TIMEOUT) * 1000;
blunderEnabled = prefs.getBool("blunder", false);
progressiveDifficulty = prefs.getBool("evolve", false);
blunderChance = prefs.getUChar("blPct", 20);
loadGameLog();
FastLED.addLeds<WS2812B, LED_PIN, GRB>(leds, NUM_LEDS);
FastLED.setBrightness(currentBrightness);
pinMode(ENC_SW, INPUT_PULLUP);
WiFi.softAP("Connect4-Config", WIFI_PASSWORD);
WiFi.softAP(WIFI_SSID, WIFI_PASSWORD);
server.on("/", handleRoot);
server.on("/save", HTTP_POST, handleSave);
server.begin();