/*
* Enhanced Snake Game - Wokwi Embedded Systems Project
* Platform: ESP32 + SSD1306 OLED + Joystick + Buttons + Buzzer
*
* Features:
* - Snake movement, food scoring, game over detection
* - Speed boost (hold joystick button)
* - Poison food (red X, -2 score penalty)
* - Dynamic obstacles (spawn every 5 foods eaten)
* - Local leaderboard (Top 5 via ESP32 Preferences/NVS)
* - State machine: MENU -> COUNTDOWN -> PLAYING -> PAUSED -> GAME_OVER
*/
// ============================================================
// LIBRARIES
// ============================================================
#include <Arduino.h>
#include <Wire.h>
#include <Adafruit_SSD1306.h>
#include <Adafruit_GFX.h>
#include <Preferences.h>
// ============================================================
// CONFIGURATION
// ============================================================
// Display
constexpr uint8_t OLED_ADDR = 0x3C;
constexpr uint8_t SCR_W = 128;
constexpr uint8_t SCR_H = 64;
constexpr uint8_t HUD_H = 8;
constexpr uint8_t CELL = 6;
constexpr uint8_t COLS = 20;
constexpr uint8_t ROWS = 9;
constexpr uint8_t GRID_X = (SCR_W - COLS * CELL) / 2;
constexpr uint8_t GRID_Y = HUD_H;
// Pins (ESP32 DevKit)
constexpr uint8_t JOY_X = 34;
constexpr uint8_t JOY_Y = 35;
constexpr uint8_t JOY_SW = 32;
constexpr uint8_t BTN_START = 25;
constexpr uint8_t BTN_LB = 26;
constexpr uint8_t BUZZER = 27;
// Game timing
constexpr uint16_t BASE_IV = 250;
constexpr uint16_t MIN_IV = 60;
constexpr uint16_t IV_STEP = 12;
constexpr uint8_t INIT_LEN = 3;
constexpr uint8_t MAX_OBS = 8;
constexpr uint8_t OBS_EVERY = 5;
constexpr uint8_t POISON_PCT = 30;
constexpr uint8_t MIN_LEN = 2;
// Dark mode
constexpr uint8_t DARK_RADIUS = 4;
constexpr uint8_t DARK_SCORE_THRESHOLD = 10;
// Random map
constexpr uint8_t MAP_WALL_DENSITY = 12; // % of cells as walls
constexpr uint8_t MAP_SAFE_RADIUS = 3; // clear zone around snake spawn
// AI snake
constexpr int8_t AI_START_X = COLS - 3;
constexpr int8_t AI_START_Y = ROWS - 1;
// States
enum GameState : uint8_t {
ST_MENU, ST_COUNTDOWN, ST_PLAYING, ST_PAUSED, ST_OVER, ST_LB
};
enum Direction : uint8_t { DIR_NONE, DIR_UP, DIR_DOWN, DIR_LEFT, DIR_RIGHT };
// Game mode flags (bitmask)
constexpr uint8_t MODE_DARK = 0x01;
constexpr uint8_t MODE_MAP = 0x02;
constexpr uint8_t MODE_AI = 0x04;
constexpr uint8_t MODE_SKILL = 0x08;
// Skill system
constexpr uint8_t SKILL_CHANCE = 15; // 15% spawn chance after eating food
constexpr uint16_t SK_SPEED_DUR = 10000; // 10 seconds
constexpr uint16_t SK_GHOST_DUR = 8000; // 8 seconds
constexpr uint16_t SK_BONUS_DUR = 15000; // 15 seconds
constexpr uint8_t MAX_ACTIVE_SKILLS = 3;
enum SkillType : uint8_t { SK_NONE, SK_SPEED, SK_GHOST, SK_BONUS };
constexpr uint8_t SKM_SPEED = 0x01;
constexpr uint8_t SKM_GHOST = 0x02;
constexpr uint8_t SKM_BONUS = 0x04;
// Tones (Hz)
constexpr uint16_t T_EAT = 880;
constexpr uint16_t T_POISON = 220;
constexpr uint16_t T_DIE = 150;
constexpr uint16_t T_START = 660;
constexpr uint16_t T_HS = 1047;
constexpr uint16_t T_SKILL = 1200;
// ============================================================
// FORWARD DECLARATIONS
// ============================================================
struct MapGenerator;
struct SkillItem;
// ============================================================
// POINT & SNAKE
// ============================================================
struct Point {
int8_t x, y;
};
struct Snake {
Point body[COLS * ROWS];
uint16_t len;
int8_t dx, dy;
void init() {
len = INIT_LEN;
dx = 1; dy = 0;
int8_t sx = COLS / 2;
int8_t sy = ROWS / 2;
for (uint16_t i = 0; i < len; i++) {
body[i].x = sx - i;
body[i].y = sy;
}
}
Point head() const { return body[0]; }
void setDir(int8_t nx, int8_t ny) {
if (len > 1 && nx == -dx && ny == -dy) return;
dx = nx; dy = ny;
}
bool move(const MapGenerator* map = nullptr, bool ghost = false);
void grow() {
if (len >= COLS * ROWS) return;
body[len] = body[len - 1];
len++;
}
bool occupies(int8_t x, int8_t y) const {
for (uint16_t i = 0; i < len; i++)
if (body[i].x == x && body[i].y == y) return true;
return false;
}
};
// ============================================================
// FOOD SYSTEM
// ============================================================
struct FoodSystem {
Point normal;
Point poison;
bool poisonActive;
void init() { poisonActive = false; }
void spawnNormal(const Snake& snake, const Point obs[], uint8_t obsCnt,
const MapGenerator* map = nullptr, const Snake* aiSnake = nullptr,
const struct SkillItem* skill = nullptr);
void spawnPoison(const Snake& snake, const Point obs[], uint8_t obsCnt,
const MapGenerator* map = nullptr, const Snake* aiSnake = nullptr,
const struct SkillItem* skill = nullptr);
void removePoison() { poisonActive = false; }
};
// ============================================================
// OBSTACLE SYSTEM
// ============================================================
struct ObstacleSystem {
Point items[MAX_OBS];
uint8_t count;
uint8_t foodsSince;
void init() { count = 0; foodsSince = 0; }
void onFoodEaten(const Snake& snake, const Point& food,
const Point& poison, bool pActive,
const MapGenerator* map = nullptr, const Snake* aiSnake = nullptr) {
foodsSince++;
if (foodsSince >= OBS_EVERY && count < MAX_OBS) {
spawnOne(snake, food, poison, pActive, map, aiSnake);
foodsSince = 0;
}
}
void spawnOne(const Snake& snake, const Point& food,
const Point& poison, bool pActive,
const MapGenerator* map = nullptr, const Snake* aiSnake = nullptr,
const struct SkillItem* skill = nullptr);
bool check(const Point& p) const {
for (uint8_t i = 0; i < count; i++)
if (items[i].x == p.x && items[i].y == p.y) return true;
return false;
}
};
// ============================================================
// INPUT SYSTEM
// ============================================================
struct Input {
int16_t zeroX, zeroY;
bool lastBtnStart, lastBtnLB, lastBtnJoy;
bool btnStartEdge, btnLBEdge, btnJoyEdge;
bool btnJoyHeld;
Direction lastJoyDir;
void init() {
zeroX = analogRead(JOY_X);
zeroY = analogRead(JOY_Y);
lastBtnStart = lastBtnLB = lastBtnJoy = false;
btnStartEdge = btnLBEdge = btnJoyEdge = false;
btnJoyHeld = false;
lastJoyDir = DIR_NONE;
pinMode(BTN_START, INPUT_PULLUP);
pinMode(BTN_LB, INPUT_PULLUP);
pinMode(JOY_SW, INPUT_PULLUP);
}
void update() {
btnStartEdge = btnLBEdge = btnJoyEdge = false;
bool s = digitalRead(BTN_START) == LOW;
bool l = digitalRead(BTN_LB) == LOW;
bool j = digitalRead(JOY_SW) == LOW;
if (s && !lastBtnStart) btnStartEdge = true;
if (l && !lastBtnLB) btnLBEdge = true;
if (j && !lastBtnJoy) btnJoyEdge = true;
lastBtnStart = s; lastBtnLB = l; lastBtnJoy = j;
btnJoyHeld = j;
}
Direction readJoy() {
int16_t x = analogRead(JOY_X) - zeroX;
int16_t y = analogRead(JOY_Y) - zeroY;
const int16_t DZ = 400;
if (x > DZ) return DIR_LEFT;
if (x < -DZ) return DIR_RIGHT;
if (y > DZ) return DIR_UP;
if (y < -DZ) return DIR_DOWN;
return DIR_NONE;
}
bool startPressed() { return btnStartEdge; }
bool lbPressed() { return btnLBEdge; }
bool joyPressed() { return btnJoyEdge; }
bool joyHeld() { return btnJoyHeld; }
};
// ============================================================
// AUDIO
// ============================================================
void audioInit() {
pinMode(BUZZER, OUTPUT);
noTone(BUZZER);
}
void audioEat() { tone(BUZZER, T_EAT, 60); }
void audioPoison() { tone(BUZZER, T_POISON, 200); }
void audioDie() { tone(BUZZER, T_DIE, 400); }
void audioStart() { tone(BUZZER, T_START, 150); }
void audioHigh() { tone(BUZZER, T_HS, 100); delay(100); tone(BUZZER, T_HS, 100); }
void audioSkill() { tone(BUZZER, T_SKILL, 80); delay(40); tone(BUZZER, T_SKILL + 200, 80); }
// ============================================================
// LEADERBOARD
// ============================================================
struct ScoreEntry {
uint16_t score;
char name[4];
};
struct Leaderboard {
ScoreEntry entries[5];
Preferences prefs;
void init() {
prefs.begin("snake", false);
for (uint8_t i = 0; i < 5; i++) {
char k[8];
snprintf(k, 8, "s%d", i);
entries[i].score = prefs.getUShort(k, 0);
snprintf(k, 8, "n%d", i);
String n = prefs.getString(k, "---");
strncpy(entries[i].name, n.c_str(), 3);
entries[i].name[3] = '\0';
}
}
void save() {
for (uint8_t i = 0; i < 5; i++) {
char k[8];
snprintf(k, 8, "s%d", i);
prefs.putUShort(k, entries[i].score);
snprintf(k, 8, "n%d", i);
prefs.putString(k, entries[i].name);
}
}
bool isHighScore(uint16_t s) {
if (s == 0) return false;
for (uint8_t i = 0; i < 5; i++)
if (s > entries[i].score) return true;
return false;
}
void insert(uint16_t s, const char* n) {
uint8_t pos = 5;
for (uint8_t i = 0; i < 5; i++)
if (s > entries[i].score) { pos = i; break; }
if (pos >= 5) return;
for (uint8_t i = 4; i > pos; i--) entries[i] = entries[i - 1];
entries[pos].score = s;
strncpy(entries[pos].name, n, 3);
entries[pos].name[3] = '\0';
save();
}
};
// ============================================================
// RANDOM MAP GENERATOR
// ============================================================
struct MapGenerator {
bool walls[COLS][ROWS];
uint8_t wallCount;
void init() {
memset(walls, 0, sizeof(walls));
wallCount = 0;
}
void generate(const Snake& snake) {
init();
uint8_t target = (COLS * ROWS * MAP_WALL_DENSITY) / 100;
Point head = snake.head();
uint8_t placed = 0, attempts = 0;
while (placed < target && attempts < 500) {
int8_t x = random(COLS), y = random(ROWS);
attempts++;
if (walls[x][y]) continue;
// Keep spawn area clear
if (abs(x - head.x) <= MAP_SAFE_RADIUS && abs(y - head.y) <= MAP_SAFE_RADIUS) continue;
walls[x][y] = true;
placed++;
}
wallCount = placed;
}
bool isWall(int8_t x, int8_t y) const {
if (x < 0 || x >= COLS || y < 0 || y >= ROWS) return true; // OOB = wall
return walls[x][y];
}
bool blocks(int8_t x, int8_t y, const Point obs[], uint8_t obsCnt) const {
if (isWall(x, y)) return true;
for (uint8_t i = 0; i < obsCnt; i++)
if (obs[i].x == x && obs[i].y == y) return true;
return false;
}
};
// ============================================================
// AI CONTROLLER
// ============================================================
struct AIController {
Snake snake;
uint8_t score;
void init() {
score = 0;
snake.len = INIT_LEN;
snake.dx = -1; snake.dy = 0;
snake.body[0].x = AI_START_X; snake.body[0].y = AI_START_Y;
snake.body[1].x = AI_START_X + 1; snake.body[1].y = AI_START_Y;
snake.body[2].x = AI_START_X + 2; snake.body[2].y = AI_START_Y;
}
void decideDir(const Point& target, const Point obs[], uint8_t obsCnt,
const Snake& otherSnake, const MapGenerator* map) {
Point head = snake.head();
int8_t bestDx = snake.dx, bestDy = snake.dy;
int16_t bestScore = -9999;
const int8_t dirs[4][2] = {{1,0}, {-1,0}, {0,1}, {0,-1}};
for (uint8_t i = 0; i < 4; i++) {
int8_t ndx = dirs[i][0], ndy = dirs[i][1];
// Prevent 180-degree turn
if (snake.len > 1 && ndx == -snake.dx && ndy == -snake.dy) continue;
Point nh = {(int8_t)(head.x + ndx), (int8_t)(head.y + ndy)};
// Wall collision
if (nh.x < 0 || nh.x >= COLS || nh.y < 0 || nh.y >= ROWS) continue;
if (map && map->isWall(nh.x, nh.y)) continue;
// Self collision (excluding tail)
bool selfHit = false;
for (uint16_t j = 0; j < snake.len - 1; j++) {
if (snake.body[j].x == nh.x && snake.body[j].y == nh.y) { selfHit = true; break; }
}
if (selfHit) continue;
// Obstacle collision
bool obsHit = false;
for (uint8_t j = 0; j < obsCnt; j++) {
if (obs[j].x == nh.x && obs[j].y == nh.y) { obsHit = true; break; }
}
if (obsHit) continue;
// Other snake body collision
if (otherSnake.occupies(nh.x, nh.y)) continue;
// Score: negative Manhattan distance to target + bias for current dir
int16_t dist = abs(nh.x - target.x) + abs(nh.y - target.y);
int16_t score = -dist;
if (ndx == snake.dx && ndy == snake.dy) score += 2;
if (score > bestScore) {
bestScore = score;
bestDx = ndx; bestDy = ndy;
}
}
snake.dx = bestDx; snake.dy = bestDy;
}
};
// ============================================================
// SKILL SYSTEM
// ============================================================
struct SkillItem {
SkillType type;
Point pos;
bool active;
void init() { type = SK_NONE; active = false; }
};
struct SkillSlot {
SkillType type;
uint32_t startMs;
uint32_t durationMs;
void init() { type = SK_NONE; startMs = 0; durationMs = 0; }
bool expired() const { return type == SK_NONE || (millis() - startMs) >= durationMs; }
};
struct ActiveSkills {
SkillSlot slots[MAX_ACTIVE_SKILLS];
uint8_t mask;
void init() { mask = 0; for (uint8_t i = 0; i < MAX_ACTIVE_SKILLS; i++) slots[i].init(); }
bool has(SkillType t) const {
uint8_t m = (t == SK_SPEED) ? SKM_SPEED : (t == SK_GHOST) ? SKM_GHOST :
(t == SK_BONUS) ? SKM_BONUS : 0;
return mask & m;
}
void add(SkillType t) {
uint8_t m = (t == SK_SPEED) ? SKM_SPEED : (t == SK_GHOST) ? SKM_GHOST :
(t == SK_BONUS) ? SKM_BONUS : 0;
if (mask & m) return; // already active
// Find empty slot
for (uint8_t i = 0; i < MAX_ACTIVE_SKILLS; i++) {
if (slots[i].type == SK_NONE) {
slots[i].type = t;
slots[i].startMs = millis();
slots[i].durationMs = (t == SK_SPEED) ? SK_SPEED_DUR :
(t == SK_GHOST) ? SK_GHOST_DUR : SK_BONUS_DUR;
mask |= m;
return;
}
}
}
void clearExpired() {
for (uint8_t i = 0; i < MAX_ACTIVE_SKILLS; i++) {
if (slots[i].type != SK_NONE && slots[i].expired()) {
uint8_t m = (slots[i].type == SK_SPEED) ? SKM_SPEED :
(slots[i].type == SK_GHOST) ? SKM_GHOST : SKM_BONUS;
mask &= ~m;
slots[i].init();
}
}
}
bool ghostActive() const { return has(SK_GHOST); }
bool bonusActive() const { return has(SK_BONUS); }
bool speedActive() const { return has(SK_SPEED); }
};
// ============================================================
// OUT-OF-LINE METHOD BODIES (need complete types)
// ============================================================
inline bool Snake::move(const MapGenerator* map, bool ghost) {
Point nh = { body[0].x + dx, body[0].y + dy };
if (!ghost) {
if (nh.x < 0 || nh.x >= COLS || nh.y < 0 || nh.y >= ROWS) return false;
if (map && map->isWall(nh.x, nh.y)) return false;
}
for (uint16_t i = 0; i < len - 1; i++) {
if (body[i].x == nh.x && body[i].y == nh.y) return false;
}
for (uint16_t i = len - 1; i > 0; i--) body[i] = body[i - 1];
body[0] = nh;
return true;
}
inline void FoodSystem::spawnNormal(const Snake& snake, const Point obs[], uint8_t obsCnt,
const MapGenerator* map, const Snake* aiSnake, const struct SkillItem* skill) {
int8_t x, y; bool ok;
do {
x = random(COLS); y = random(ROWS); ok = true;
if (snake.occupies(x, y)) ok = false;
if (aiSnake && aiSnake->occupies(x, y)) ok = false;
for (uint8_t i = 0; i < obsCnt; i++)
if (obs[i].x == x && obs[i].y == y) ok = false;
if (poisonActive && poison.x == x && poison.y == y) ok = false;
if (map && map->isWall(x, y)) ok = false;
if (skill && skill->active && skill->pos.x == x && skill->pos.y == y) ok = false;
} while (!ok);
normal.x = x; normal.y = y;
}
inline void FoodSystem::spawnPoison(const Snake& snake, const Point obs[], uint8_t obsCnt,
const MapGenerator* map, const Snake* aiSnake, const struct SkillItem* skill) {
int8_t x, y; bool ok; uint8_t tries = 0;
do {
x = random(COLS); y = random(ROWS); ok = true;
if (snake.occupies(x, y)) ok = false;
if (normal.x == x && normal.y == y) ok = false;
if (aiSnake && aiSnake->occupies(x, y)) ok = false;
for (uint8_t i = 0; i < obsCnt; i++)
if (obs[i].x == x && obs[i].y == y) ok = false;
if (map && map->isWall(x, y)) ok = false;
if (skill && skill->active && skill->pos.x == x && skill->pos.y == y) ok = false;
} while (!ok && ++tries < 100);
if (ok) { poison.x = x; poison.y = y; poisonActive = true; }
}
inline void ObstacleSystem::spawnOne(const Snake& snake, const Point& food,
const Point& poison, bool pActive,
const MapGenerator* map, const Snake* aiSnake, const struct SkillItem* skill) {
int8_t hx = snake.head().x, hy = snake.head().y;
int8_t x, y; bool ok; uint8_t tries = 0;
do {
x = random(COLS); y = random(ROWS); ok = true;
if (abs(x - hx) <= 3 && abs(y - hy) <= 3) ok = false;
if (snake.occupies(x, y)) ok = false;
if (aiSnake && aiSnake->occupies(x, y)) ok = false;
if (food.x == x && food.y == y) ok = false;
if (pActive && poison.x == x && poison.y == y) ok = false;
for (uint8_t i = 0; i < count; i++)
if (items[i].x == x && items[i].y == y) ok = false;
if (map && map->isWall(x, y)) ok = false;
if (skill && skill->active && skill->pos.x == x && skill->pos.y == y) ok = false;
} while (!ok && ++tries < 100);
if (ok) { items[count].x = x; items[count].y = y; count++; }
}
// ============================================================
// HELPERS
// ============================================================
// Chebyshev distance visibility check for dark mode
inline bool inRadius(int8_t cx, int8_t cy, const Point& center, uint8_t r) {
return (abs(cx - center.x) <= (int8_t)r) && (abs(cy - center.y) <= (int8_t)r);
}
// Spawn a skill power-up item on the field
void spawnSkillItem(SkillItem& item, const Snake& snake, const Snake* aiSnake,
const Point& normalFood, const Point& poisonFood, bool poisonActive,
const Point obs[], uint8_t obsCnt, const MapGenerator* map) {
SkillType types[] = {SK_SPEED, SK_GHOST, SK_BONUS};
item.type = types[random(3)];
int8_t x, y; bool ok; uint8_t tries = 0;
do {
x = random(COLS); y = random(ROWS); ok = true;
if (snake.occupies(x, y)) ok = false;
if (aiSnake && aiSnake->occupies(x, y)) ok = false;
if (normalFood.x == x && normalFood.y == y) ok = false;
if (poisonActive && poisonFood.x == x && poisonFood.y == y) ok = false;
for (uint8_t i = 0; i < obsCnt; i++)
if (obs[i].x == x && obs[i].y == y) ok = false;
if (map && map->isWall(x, y)) ok = false;
if (item.active && item.pos.x == x && item.pos.y == y) ok = false;
} while (!ok && ++tries < 100);
if (ok) { item.pos.x = x; item.pos.y = y; item.active = true; }
else item.active = false;
}
// ============================================================
// RENDERER
// ============================================================
struct Renderer {
Adafruit_SSD1306 d;
void init() {
d = Adafruit_SSD1306(SCR_W, SCR_H, &Wire, -1);
d.begin(SSD1306_SWITCHCAPVCC, OLED_ADDR);
d.clearDisplay();
d.setTextSize(1);
d.setTextColor(SSD1306_WHITE);
d.display();
}
void clear() { d.clearDisplay(); }
void show() { d.display(); }
int16_t gx(int8_t col) { return GRID_X + col * CELL; }
int16_t gy(int8_t row) { return GRID_Y + row * CELL; }
void drawHUD(uint16_t score, uint8_t lvl, bool fast, bool dark, const ActiveSkills* skills = nullptr) {
d.setCursor(0, 0);
d.print("SC:"); d.print(score);
d.print(" LV:"); d.print(lvl);
// Right-aligned indicators, print right-to-left
int16_t cx = 122;
if (skills && skills->bonusActive()) { cx -= 12; d.setCursor(cx, 0); d.print("x2"); }
if (skills && skills->ghostActive()) { cx -= 6; d.setCursor(cx, 0); d.print("G"); }
if (skills && skills->speedActive()) { cx -= 6; d.setCursor(cx, 0); d.print("S"); }
if (dark) { cx -= 6; d.setCursor(cx, 0); d.print("D"); }
if (fast) { cx -= 12; d.setCursor(cx, 0); d.print(">>"); }
}
void drawSnake(const Snake& s, bool ghost, bool dark, const Point& visHead) {
for (uint16_t i = 0; i < s.len; i++) {
if (dark && !inRadius(s.body[i].x, s.body[i].y, visHead, DARK_RADIUS)) continue;
int16_t x = gx(s.body[i].x), y = gy(s.body[i].y);
if (ghost && i % 2 != 0) continue; // dashed body in ghost mode
if (i == 0)
d.fillRect(x, y, CELL, CELL, SSD1306_WHITE);
else
d.fillRect(x + 1, y + 1, CELL - 2, CELL - 2, SSD1306_WHITE);
}
}
void drawAISnake(const Snake& s, bool dark, const Point& visHead) {
for (uint16_t i = 0; i < s.len; i++) {
if (dark && !inRadius(s.body[i].x, s.body[i].y, visHead, DARK_RADIUS)) continue;
int16_t x = gx(s.body[i].x), y = gy(s.body[i].y);
if (i == 0)
d.drawRect(x, y, CELL, CELL, SSD1306_WHITE); // outlined head
else
d.drawRect(x + 1, y + 1, CELL - 2, CELL - 2, SSD1306_WHITE); // hollow body
}
}
void drawFood(const Point& normal, const Point& poison, bool pActive,
bool dark, const Point& visHead) {
if (!dark || inRadius(normal.x, normal.y, visHead, DARK_RADIUS)) {
int16_t cx = gx(normal.x) + CELL / 2;
int16_t cy = gy(normal.y) + CELL / 2;
d.fillCircle(cx, cy, 2, SSD1306_WHITE);
}
if (pActive && (!dark || inRadius(poison.x, poison.y, visHead, DARK_RADIUS))) {
int16_t x = gx(poison.x), y = gy(poison.y);
d.drawLine(x + 1, y + 1, x + CELL - 2, y + CELL - 2, SSD1306_WHITE);
d.drawLine(x + CELL - 2, y + 1, x + 1, y + CELL - 2, SSD1306_WHITE);
}
}
void drawObstacles(const Point items[], uint8_t cnt, bool dark, const Point& visHead) {
for (uint8_t i = 0; i < cnt; i++) {
if (dark && !inRadius(items[i].x, items[i].y, visHead, DARK_RADIUS)) continue;
int16_t x = gx(items[i].x) + 1, y = gy(items[i].y) + 1;
d.fillRect(x, y, CELL - 2, CELL - 2, SSD1306_WHITE);
}
}
void drawSkillItem(const SkillItem& item, bool dark, const Point& visHead) {
if (!item.active) return;
if (dark && !inRadius(item.pos.x, item.pos.y, visHead, DARK_RADIUS)) return;
int16_t cx = gx(item.pos.x) + CELL / 2;
int16_t cy = gy(item.pos.y) + CELL / 2;
// Diamond shape
d.drawLine(cx, cy - 2, cx + 2, cy, SSD1306_WHITE);
d.drawLine(cx + 2, cy, cx, cy + 2, SSD1306_WHITE);
d.drawLine(cx, cy + 2, cx - 2, cy, SSD1306_WHITE);
d.drawLine(cx - 2, cy, cx, cy - 2, SSD1306_WHITE);
}
void drawDarkBorder() {
d.drawRect(GRID_X - 1, GRID_Y - 1, COLS * CELL + 2, ROWS * CELL + 2, SSD1306_WHITE);
}
void drawWalls(const MapGenerator& map, bool dark, const Point& visHead) {
for (int8_t x = 0; x < COLS; x++) {
for (int8_t y = 0; y < ROWS; y++) {
if (!map.walls[x][y]) continue;
if (dark && !inRadius(x, y, visHead, DARK_RADIUS)) continue;
int16_t px = gx(x), py = gy(y);
d.drawLine(px + 1, py + 1, px + CELL - 2, py + CELL - 2, SSD1306_WHITE);
d.drawLine(px + CELL - 2, py + 1, px + 1, py + CELL - 2, SSD1306_WHITE);
}
}
}
void drawMenu(const Leaderboard& lb, uint8_t modeFlags, uint8_t modeSelect) {
d.clearDisplay();
d.setTextSize(1);
d.setCursor(32, 0); d.print("SNAKE GAME");
const char* names[] = {"Dark", "Map", "AI", "Skill"};
for (uint8_t i = 0; i < 4; i++) {
uint8_t y = 12 + i * 10;
d.setCursor(8, y);
d.print(modeSelect == i ? ">" : " ");
d.print((modeFlags & (1 << i)) ? "[X] " : "[ ] ");
d.print(names[i]);
}
d.setCursor(4, 54);
d.print("START:Play");
d.setCursor(78, 54);
d.print("HS:");
d.print(lb.entries[0].score);
d.display();
}
void drawCountdown(uint8_t n) {
d.clearDisplay();
d.setTextSize(2);
d.setCursor(SCR_W / 2 - 12, SCR_H / 2 - 12);
d.print(n);
d.setTextSize(1);
d.display();
}
void drawPause() {
d.fillRect(30, 22, 68, 22, SSD1306_BLACK);
d.drawRect(30, 22, 68, 22, SSD1306_WHITE);
d.setCursor(38, 28); d.print("PAUSED");
d.display();
}
void drawGameOver(uint16_t score, bool newHS) {
d.fillRect(16, 16, 96, 34, SSD1306_BLACK);
d.drawRect(16, 16, 96, 34, SSD1306_WHITE);
d.setCursor(24, 20); d.print("GAME OVER");
d.setCursor(24, 32); d.print("Score:");
d.print(score);
if (newHS) { d.setCursor(88, 32); d.print("NEW!"); }
d.display();
}
void drawLeaderboard(const Leaderboard& lb) {
d.clearDisplay();
d.setCursor(25, 0); d.print("LEADERBOARD");
for (uint8_t i = 0; i < 5; i++) {
d.setCursor(8, 12 + i * 10);
d.print(i + 1); d.print(". ");
d.print(lb.entries[i].name); d.print(" ");
d.print(lb.entries[i].score);
}
d.setCursor(12, SCR_H - 8); d.print("Btn to return");
d.display();
}
};
// ============================================================
// GAME ORCHESTRATOR
// ============================================================
struct Game {
Snake snake;
FoodSystem food;
ObstacleSystem obs;
MapGenerator map;
AIController ai;
SkillItem skillItem;
ActiveSkills activeSkills;
Renderer renderer;
Input input;
Leaderboard lb;
GameState st;
uint16_t score;
uint8_t lvl;
uint16_t moveIv;
bool fast;
uint32_t lastMove;
uint32_t cdStart;
uint8_t cdNum;
bool newHS;
uint8_t modeFlags;
uint8_t modeSelect;
Direction lastMenuDir;
uint32_t lastMenuNav;
bool lastMenuBtn;
void init() {
renderer.init();
audioInit();
input.init();
lb.init();
modeFlags = 0;
modeSelect = 0;
lastMenuDir = DIR_NONE;
lastMenuNav = 0;
lastMenuBtn = false;
reset();
st = ST_MENU;
renderer.drawMenu(lb, modeFlags, modeSelect);
}
void reset() {
snake.init();
food.init();
obs.init();
if (modeFlags & MODE_MAP) map.generate(snake); else map.init();
if (modeFlags & MODE_AI) ai.init();
skillItem.init();
activeSkills.init();
score = 0; lvl = 0;
moveIv = BASE_IV;
fast = false; newHS = false;
MapGenerator* mapPtr = (modeFlags & MODE_MAP) ? &map : nullptr;
Snake* aiPtr = (modeFlags & MODE_AI) ? &ai.snake : nullptr;
food.spawnNormal(snake, obs.items, obs.count, mapPtr, aiPtr, &skillItem);
}
void loop() {
input.update();
switch (st) {
case ST_MENU: menu(); break;
case ST_COUNTDOWN: cd(); break;
case ST_PLAYING: play(); break;
case ST_PAUSED: paused(); break;
case ST_OVER: over(); break;
case ST_LB: lbView(); break;
}
}
void menu() {
// Dual detection: direct pin read + edge from Input (catches short Wokwi pulses)
bool joyBtnNow = digitalRead(JOY_SW) == LOW;
if ((joyBtnNow && !lastMenuBtn) || input.joyPressed()) {
modeFlags ^= (1 << modeSelect);
renderer.drawMenu(lb, modeFlags, modeSelect);
}
lastMenuBtn = joyBtnNow;
if (input.startPressed()) {
reset();
st = ST_COUNTDOWN;
cdStart = millis(); cdNum = 3;
audioStart();
renderer.drawCountdown(cdNum);
return;
}
if (input.lbPressed()) {
st = ST_LB;
renderer.drawLeaderboard(lb);
return;
}
// Direction navigation with 200ms debounce
Direction dir = input.readJoy();
uint32_t now = millis();
if (dir != DIR_NONE && (dir != lastMenuDir || now - lastMenuNav > 200)) {
if (dir == DIR_UP) modeSelect = (modeSelect > 0) ? modeSelect - 1 : 3;
if (dir == DIR_DOWN) modeSelect = (modeSelect < 3) ? modeSelect + 1 : 0;
lastMenuDir = dir;
lastMenuNav = now;
renderer.drawMenu(lb, modeFlags, modeSelect);
}
if (dir == DIR_NONE) lastMenuDir = DIR_NONE;
}
void cd() {
if (millis() - cdStart >= 1000) {
cdStart = millis();
if (--cdNum == 0) {
st = ST_PLAYING;
lastMove = millis();
bool darkActive = (modeFlags & MODE_DARK) && score >= DARK_SCORE_THRESHOLD;
bool mapOn = modeFlags & MODE_MAP;
Point head = snake.head();
renderer.clear();
renderer.drawHUD(score, lvl, fast, darkActive);
if (darkActive) renderer.drawDarkBorder();
if (mapOn) renderer.drawWalls(map, darkActive, head);
renderer.drawObstacles(obs.items, obs.count, darkActive, head);
renderer.drawFood(food.normal, food.poison, food.poisonActive, darkActive, head);
renderer.drawSnake(snake, false, darkActive, head);
renderer.show();
} else {
renderer.drawCountdown(cdNum);
}
}
}
void play() {
bool mapOn = modeFlags & MODE_MAP;
bool aiOn = modeFlags & MODE_AI;
bool skillOn = modeFlags & MODE_SKILL;
// --- Clear expired skills ---
if (skillOn) activeSkills.clearExpired();
bool ghostActive = skillOn && activeSkills.ghostActive();
bool bonusActive = skillOn && activeSkills.bonusActive();
bool speedActive = skillOn && activeSkills.speedActive();
Direction dir = input.readJoy();
switch (dir) {
case DIR_UP: snake.setDir(0, -1); break;
case DIR_DOWN: snake.setDir(0, 1); break;
case DIR_LEFT: snake.setDir(-1, 0); break;
case DIR_RIGHT: snake.setDir(1, 0); break;
default: break;
}
fast = input.joyHeld();
if (input.startPressed()) {
st = ST_PAUSED;
renderer.drawPause();
return;
}
// Timing: fast mode + speed skill stack
uint16_t iv = fast ? moveIv / 3 : moveIv;
if (speedActive) iv /= 2;
if (millis() - lastMove < iv) return;
lastMove = millis();
MapGenerator* mapPtr = mapOn ? &map : nullptr;
Snake* aiPtr = aiOn ? &ai.snake : nullptr;
// --- AI decision ---
if (aiOn) {
ai.decideDir(food.normal, obs.items, obs.count, snake, mapPtr);
}
// --- Player move (ghost skips walls/map) ---
if (!snake.move(mapPtr, ghostActive)) { endGame(); return; }
Point head = snake.head();
// --- AI move ---
if (aiOn) {
if (!ai.snake.move(mapPtr)) { endGame(); return; }
// Snake-snake collision (ghost skips)
if (!ghostActive) {
Point aiHead = ai.snake.head();
if (ai.snake.occupies(head.x, head.y) || snake.occupies(aiHead.x, aiHead.y)) {
endGame(); return;
}
}
}
// --- Player: Normal food ---
if (head.x == food.normal.x && head.y == food.normal.y) {
snake.grow();
uint16_t pts = bonusActive ? 2 : 1;
score += pts;
lvl = score / 5;
moveIv = constrain(BASE_IV - score * IV_STEP, MIN_IV, BASE_IV);
audioEat();
obs.onFoodEaten(snake, food.normal, food.poison, food.poisonActive, mapPtr, aiPtr);
food.spawnNormal(snake, obs.items, obs.count, mapPtr, aiPtr, &skillItem);
if (food.poisonActive) food.removePoison();
if (random(100) < POISON_PCT)
food.spawnPoison(snake, obs.items, obs.count, mapPtr, aiPtr, &skillItem);
// Skill item spawn chance
if (skillOn && !skillItem.active && random(100) < SKILL_CHANCE) {
spawnSkillItem(skillItem, snake, aiPtr, food.normal, food.poison,
food.poisonActive, obs.items, obs.count, mapPtr);
}
}
// --- AI: Normal food ---
if (aiOn) {
Point aiHead = ai.snake.head();
if (aiHead.x == food.normal.x && aiHead.y == food.normal.y) {
ai.snake.grow();
ai.score++;
food.spawnNormal(snake, obs.items, obs.count, mapPtr, aiPtr, &skillItem);
if (food.poisonActive) food.removePoison();
}
}
// --- Player: Poison food ---
if (food.poisonActive && head.x == food.poison.x && head.y == food.poison.y) {
audioPoison();
if (snake.len <= MIN_LEN) { endGame(); return; }
score = max(0, (int16_t)score - 2);
snake.len = snake.len <= MIN_LEN + 2 ? MIN_LEN : snake.len - 2;
food.removePoison();
}
// --- AI: Poison food ---
if (aiOn && food.poisonActive) {
Point aiHead = ai.snake.head();
if (aiHead.x == food.poison.x && aiHead.y == food.poison.y) {
if (ai.snake.len <= MIN_LEN) { endGame(); return; }
ai.snake.len = ai.snake.len <= MIN_LEN + 2 ? MIN_LEN : ai.snake.len - 2;
food.removePoison();
}
}
// --- Skill item pickup ---
if (skillOn && skillItem.active && head.x == skillItem.pos.x && head.y == skillItem.pos.y) {
audioSkill();
activeSkills.add(skillItem.type);
skillItem.active = false;
}
// --- Obstacle collision (ghost skips) ---
if (!ghostActive && obs.check(head)) { endGame(); return; }
if (aiOn && obs.check(ai.snake.head())) { endGame(); return; }
// --- Render ---
bool darkActive = (modeFlags & MODE_DARK) && score >= DARK_SCORE_THRESHOLD;
renderer.clear();
renderer.drawHUD(score, lvl, fast, darkActive, skillOn ? &activeSkills : nullptr);
if (darkActive) renderer.drawDarkBorder();
if (mapOn) renderer.drawWalls(map, darkActive, head);
renderer.drawObstacles(obs.items, obs.count, darkActive, head);
renderer.drawFood(food.normal, food.poison, food.poisonActive, darkActive, head);
if (skillOn) renderer.drawSkillItem(skillItem, darkActive, head);
if (aiOn) renderer.drawAISnake(ai.snake, darkActive, head);
renderer.drawSnake(snake, ghostActive, darkActive, head);
renderer.show();
}
void paused() {
if (input.startPressed()) {
st = ST_PLAYING;
lastMove = millis();
}
}
void over() {
if (millis() - lastMove < 1500) return;
if (input.startPressed() || input.joyPressed()) {
st = ST_MENU;
renderer.drawMenu(lb, modeFlags, modeSelect);
}
}
void lbView() {
if (input.startPressed() || input.lbPressed()) {
st = ST_MENU;
renderer.drawMenu(lb, modeFlags, modeSelect);
}
}
private:
void endGame() {
newHS = lb.isHighScore(score);
if (newHS) { lb.insert(score, "AAA"); audioHigh(); }
else { audioDie(); }
st = ST_OVER;
lastMove = millis();
renderer.drawGameOver(score, newHS);
}
};
// ============================================================
// MAIN
// ============================================================
Game game;
void setup() {
Serial.begin(115200);
randomSeed(analogRead(34));
game.init();
}
void loop() {
game.loop();
}