/*
* 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_MAX = 5;
constexpr uint8_t DARK_SCORE_THRESHOLD = 10;
inline uint8_t darkRadius(uint16_t s) {
if (s >= 50) return 2;
if (s >= 35) return 3;
if (s >= 20) return 4;
return 5;
}
// 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, ST_NAME
};
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;
constexpr uint8_t MENU_ITEM_COUNT = 4;
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;
bool operator==(const Point& o) const { return x == o.x && y == o.y; }
bool operator!=(const Point& o) const { return !(*this == o); }
};
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] == Point{x, 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,
uint16_t playerScore = 0) {
foodsSince++;
uint8_t interval = max((uint8_t)2, (uint8_t)(OBS_EVERY - playerScore / 10));
if (foodsSince >= interval && 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] == p) 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
// ============================================================
// Non-blocking tone sequencer state
static uint32_t toneSeqEndMs = 0;
static uint16_t toneSeqFreq2 = 0;
static uint16_t toneSeqDur2 = 0;
static bool toneSeqPending = false;
void audioInit() {
pinMode(BUZZER, OUTPUT);
noTone(BUZZER);
}
void audioPlaySeq(uint16_t freq1, uint16_t dur1, uint16_t gapMs,
uint16_t freq2, uint16_t dur2) {
tone(BUZZER, freq1, dur1);
toneSeqEndMs = millis() + dur1 + gapMs;
toneSeqFreq2 = freq2;
toneSeqDur2 = dur2;
toneSeqPending = true;
}
void audioTick() {
if (!toneSeqPending) return;
if (millis() >= toneSeqEndMs) {
tone(BUZZER, toneSeqFreq2, toneSeqDur2);
toneSeqPending = false;
}
}
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 audioDoubleBeep(uint16_t freq, uint16_t dur, uint16_t gap) {
audioPlaySeq(freq, dur, gap, freq, dur);
}
void audioHigh() { audioDoubleBeep(T_HS, 100, 100); }
void audioSkill() { audioPlaySeq(T_SKILL, 80, 40, 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];
}
};
// ============================================================
// 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;
}
// Count reachable cells from start within maxDepth, avoiding walls/self/other/obstacles
static int16_t bfsReachable(const Point& start, const MapGenerator* map,
const Snake& self, const Snake& other,
const Point obs[], uint8_t obsCnt, uint8_t maxDepth) {
Point queue[80];
bool visited[COLS][ROWS];
memset(visited, 0, sizeof(visited));
uint8_t head = 0, tail = 0;
queue[tail++] = start;
visited[start.x][start.y] = true;
int16_t count = 0;
const int8_t dirs[4][2] = {{1,0}, {-1,0}, {0,1}, {0,-1}};
for (uint8_t d = 0; d < maxDepth && head < tail; d++) {
uint8_t levelEnd = tail;
for (; head < levelEnd; head++) {
Point p = queue[head];
for (uint8_t i = 0; i < 4; i++) {
int8_t nx = p.x + dirs[i][0], ny = p.y + dirs[i][1];
if (nx < 0 || nx >= COLS || ny < 0 || ny >= ROWS) continue;
if (visited[nx][ny]) continue;
if (map && map->isWall(nx, ny)) continue;
Point np = {nx, ny};
bool blocked = false;
for (uint8_t j = 0; j < obsCnt; j++) {
if (obs[j] == np) { blocked = true; break; }
}
if (blocked) continue;
if (other.occupies(nx, ny)) continue;
bool selfBlocked = false;
for (uint16_t j = 0; j < self.len - 1; j++) {
if (self.body[j] == np) { selfBlocked = true; break; }
}
if (selfBlocked) continue;
visited[nx][ny] = true;
queue[tail++] = np;
count++;
}
}
}
return count;
}
void decideDir(const Point& target, const Point obs[], uint8_t obsCnt,
const Snake& otherSnake, const MapGenerator* map,
const Point* poison, bool poisonActive) {
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] == nh) { selfHit = true; break; }
}
if (selfHit) continue;
// Obstacle collision
bool obsHit = false;
for (uint8_t j = 0; j < obsCnt; j++) {
if (obs[j] == nh) { 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;
// Avoid poison food
if (poisonActive && nh == *poison) score -= 500;
// BFS safety: prefer directions with more open space
score += bfsReachable(nh, map, snake, otherSnake, obs, obsCnt, 6) * 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;
static uint8_t toMask(SkillType t) {
return (t == SK_SPEED) ? SKM_SPEED : (t == SK_GHOST) ? SKM_GHOST :
(t == SK_BONUS) ? SKM_BONUS : 0;
}
static uint16_t toDuration(SkillType t) {
return (t == SK_SPEED) ? SK_SPEED_DUR : (t == SK_GHOST) ? SK_GHOST_DUR : SK_BONUS_DUR;
}
void init() { mask = 0; for (uint8_t i = 0; i < MAX_ACTIVE_SKILLS; i++) slots[i].init(); }
bool has(SkillType t) const { return mask & toMask(t); }
void add(SkillType t) {
uint8_t m = toMask(t);
if (mask & m) return;
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 = toDuration(t);
mask |= m;
return;
}
}
}
void clearExpired() {
for (uint8_t i = 0; i < MAX_ACTIVE_SKILLS; i++) {
if (slots[i].type != SK_NONE && slots[i].expired()) {
mask &= ~toMask(slots[i].type);
slots[i].init();
}
}
}
bool ghostActive() const { return has(SK_GHOST); }
bool bonusActive() const { return has(SK_BONUS); }
bool speedActive() const { return has(SK_SPEED); }
};
// ============================================================
// HELPERS (in struct to prevent Arduino preprocessor hoisting)
// ============================================================
struct H {
static inline bool isOccupied(int8_t x, int8_t y,
const Snake& snake, const Snake* aiSnake,
const Point obs[], uint8_t obsCnt,
const MapGenerator* map,
const SkillItem* skill) {
if (snake.occupies(x, y)) return true;
if (aiSnake && aiSnake->occupies(x, y)) return true;
for (uint8_t i = 0; i < obsCnt; i++)
if (obs[i] == Point{x, y}) return true;
if (map && map->isWall(x, y)) return true;
if (skill && skill->active && skill->pos == Point{x, y}) return true;
return false;
}
static 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);
}
static 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 = !isOccupied(x, y, snake, aiSnake, obs, obsCnt, map, nullptr) &&
!(normalFood == Point{x, y}) &&
!(poisonActive && poisonFood == Point{x, y});
} while (!ok && ++tries < 100);
if (ok) { item.pos.x = x; item.pos.y = y; item.active = true; }
else item.active = false;
}
};
// ============================================================
// 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] == nh) 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 = !H::isOccupied(x, y, snake, aiSnake, obs, obsCnt, map, skill) &&
!(poisonActive && poison == Point{x, y});
} 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 = !H::isOccupied(x, y, snake, aiSnake, obs, obsCnt, map, skill) &&
!(normal == Point{x, y});
} 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 (H::isOccupied(x, y, snake, aiSnake, items, count, map, skill)) ok = false;
if (food == Point{x, y}) ok = false;
if (pActive && poison == Point{x, y}) ok = false;
} while (!ok && ++tries < 100);
if (ok) { items[count].x = x; items[count].y = y; count++; }
}
// ============================================================
// RENDERER
// ============================================================
struct Renderer {
Adafruit_SSD1306 d;
uint8_t darkR;
bool poisonFlash;
bool aiFlash;
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();
darkR = DARK_RADIUS_MAX;
poisonFlash = false;
aiFlash = false;
}
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 drawSkillBars(const ActiveSkills* skills) {
if (!skills) return;
uint8_t barY = SCR_H - 1;
uint8_t idx = 0;
for (uint8_t i = 0; i < MAX_ACTIVE_SKILLS; i++) {
if (skills->slots[i].type == SK_NONE) continue;
uint32_t remain = skills->slots[i].startMs + skills->slots[i].durationMs;
if (remain <= millis()) continue;
uint8_t pct = ((remain - millis()) * 100) / skills->slots[i].durationMs;
uint8_t barW = (SCR_W * pct) / 100;
d.drawFastHLine(0, barY - idx, barW, SSD1306_WHITE);
idx++;
if (idx >= 2) break;
}
}
void drawSnake(const Snake& s, bool ghost, bool dark, const Point& visHead) {
// Blink effect in ghost mode
if (ghost && (millis() / 180) % 2 == 0) return;
// Poison flash: blink on/off every 80ms for 400ms total
if (poisonFlash && (millis() / 80) % 2 == 0) return;
for (uint16_t i = 0; i < s.len; i++) {
if (dark && !H::inRadius(s.body[i].x, s.body[i].y, visHead, darkR)) 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) {
// AI penalty flash
if (aiFlash && (millis() / 80) % 2 == 0) return;
for (uint16_t i = 0; i < s.len; i++) {
if (dark && !H::inRadius(s.body[i].x, s.body[i].y, visHead, darkR)) 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 || H::inRadius(normal.x, normal.y, visHead, darkR)) {
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 || H::inRadius(poison.x, poison.y, visHead, darkR))) {
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 && !H::inRadius(items[i].x, items[i].y, visHead, darkR)) continue;
int16_t x = gx(items[i].x), y = gy(items[i].y);
// Inverted: white border + black center
d.fillRect(x, y, CELL, CELL, SSD1306_WHITE);
d.fillRect(x + 1, y + 1, CELL - 2, CELL - 2, SSD1306_BLACK);
}
}
void drawSkillItem(const SkillItem& item, bool dark, const Point& visHead) {
if (!item.active) return;
if (dark && !H::inRadius(item.pos.x, item.pos.y, visHead, darkR)) 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) {
if (map.wallCount == 0) return;
for (int8_t x = 0; x < COLS; x++) {
for (int8_t y = 0; y < ROWS; y++) {
if (!map.walls[x][y]) continue;
if (dark && !H::inRadius(x, y, visHead, darkR)) 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(20, 0); d.print("=== SNAKE GAME ===");
d.drawFastHLine(0, 9, SCR_W, SSD1306_WHITE);
const char* names[] = {"Dark Mode", "Map Walls", "AI Snake", "Skills"};
for (uint8_t i = 0; i < 4; i++) {
uint8_t y = 13 + i * 12;
d.setCursor(4, y);
d.print(modeSelect == i ? ">" : " ");
d.print((modeFlags & (1 << i)) ? "[X]" : "[ ]");
d.setCursor(28, y);
d.print(names[i]);
}
d.drawFastHLine(0, SCR_H - 10, SCR_W, SSD1306_WHITE);
d.setCursor(4, SCR_H - 7);
d.print("START:Go LB:Top");
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, uint16_t foodEaten, uint16_t timeSec) {
d.fillRect(12, 10, 104, 46, SSD1306_BLACK);
d.drawRect(12, 10, 104, 46, SSD1306_WHITE);
d.setCursor(30, 14); d.print("GAME OVER");
d.setCursor(18, 26);
if (newHS) { d.print("NEW HS! "); }
d.print("SC:"); d.print(score);
d.setCursor(18, 36);
d.print("Food:"); d.print(foodEaten);
d.print(" T:"); d.print(timeSec); d.print("s");
d.setCursor(20, 50); d.print("Btn to menu");
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();
}
void drawNameEntry(uint16_t score, const char* name, uint8_t idx) {
d.clearDisplay();
d.setTextSize(1);
d.setCursor(18, 0); d.print("NEW HIGH SCORE!");
d.setCursor(40, 9); d.print("SC:"); d.print(score);
d.setTextSize(2);
for (uint8_t i = 0; i < 3; i++) {
d.setCursor(34 + i * 22, 24);
d.print(name[i]);
}
d.setTextSize(1);
// Underline cursor
d.drawFastHLine(34 + idx * 22, 40, 12, SSD1306_WHITE);
d.setCursor(8, SCR_H - 16);
d.print("UD:pick BTN:next");
d.setCursor(20, SCR_H - 8);
d.print("START:done");
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;
uint32_t lastMenuBtnTime;
// Name entry
char nameBuf[4];
uint8_t nameIdx;
uint32_t lastNameTime;
Direction lastNameDir;
// Flash effects
uint32_t poisonFlashStart;
uint32_t aiFlashStart;
// Stats tracking
uint16_t statsFood;
uint32_t statsStartMs;
void init() {
renderer.init();
audioInit();
input.init();
lb.init();
modeFlags = 0;
modeSelect = 0;
lastMenuDir = DIR_NONE;
lastMenuNav = 0;
lastMenuBtn = false;
lastMenuBtnTime = 0;
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;
statsFood = 0; statsStartMs = 0;
poisonFlashStart = 0; aiFlashStart = 0;
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();
audioTick();
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;
case ST_NAME: nameEntry(); break;
}
}
void menu() {
// Direct read + debounce (Wokwi button pulses too short for edge detect)
bool joyNow = digitalRead(JOY_SW) == LOW;
uint32_t now = millis();
if (joyNow && !lastMenuBtn && now - lastMenuBtnTime > 200) {
modeFlags ^= (1 << modeSelect);
renderer.drawMenu(lb, modeFlags, modeSelect);
lastMenuBtnTime = now;
}
lastMenuBtn = joyNow;
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();
now = millis();
if (dir != DIR_NONE && (dir != lastMenuDir || now - lastMenuNav > 200)) {
if (dir == DIR_UP) modeSelect = (modeSelect > 0) ? modeSelect - 1 : MENU_ITEM_COUNT - 1;
if (dir == DIR_DOWN) modeSelect = (modeSelect < MENU_ITEM_COUNT - 1) ? 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();
statsStartMs = 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, &food.poison, food.poisonActive);
}
// --- 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();
// Player head on AI body → player dies
if (ai.snake.occupies(head.x, head.y)) {
endGame(); return;
}
// AI head on player body → AI penalized only
if (snake.occupies(aiHead.x, aiHead.y)) {
ai.snake.len = (ai.snake.len > INIT_LEN + 2) ? (ai.snake.len - 2) : (uint16_t)INIT_LEN;
ai.score = (ai.score > 0) ? (ai.score - 1) : (uint8_t)0;
aiFlashStart = millis();
audioPoison();
}
}
}
// --- Player: Normal food ---
if (head == food.normal) {
snake.grow();
statsFood++;
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, score);
food.spawnNormal(snake, obs.items, obs.count, mapPtr, aiPtr, &skillItem);
if (food.poisonActive) food.removePoison();
if (random(100) < min(POISON_PCT + score / 3, 70))
food.spawnPoison(snake, obs.items, obs.count, mapPtr, aiPtr, &skillItem);
// Skill item spawn chance
if (skillOn && !skillItem.active && random(100) < SKILL_CHANCE) {
H::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 == food.normal) {
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 == food.poison) {
audioPoison();
poisonFlashStart = millis();
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 == food.poison) {
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 == skillItem.pos) {
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;
if (darkActive) renderer.darkR = darkRadius(score);
renderer.poisonFlash = poisonFlashStart && (millis() - poisonFlashStart < 400);
renderer.aiFlash = aiFlashStart && (millis() - aiFlashStart < 300);
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);
if (skillOn) renderer.drawSkillBars(&activeSkills);
renderer.show();
}
void paused() {
if (input.startPressed()) {
st = ST_PLAYING;
lastMove = 0; // force immediate render next tick
}
}
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);
}
}
static uint8_t charIdx(char c) {
if (c >= 'A' && c <= 'Z') return c - 'A';
if (c >= '0' && c <= '9') return c - '0' + 26;
return 36; // space
}
static char idxChar(uint8_t i) {
if (i < 26) return 'A' + i;
if (i < 36) return '0' + (i - 26);
return ' ';
}
void nameEntry() {
// Joystick direction: cycle characters (A-Z, 0-9, space = 37 chars)
Direction dir = input.readJoy();
uint32_t now = millis();
if (dir != DIR_NONE && (dir != lastNameDir || now - lastNameTime > 200)) {
uint8_t ci = charIdx(nameBuf[nameIdx]);
if (dir == DIR_UP) ci = (ci >= 36) ? 0 : ci + 1;
if (dir == DIR_DOWN) ci = (ci == 0) ? 36 : ci - 1;
nameBuf[nameIdx] = idxChar(ci);
lastNameDir = dir;
lastNameTime = now;
renderer.drawNameEntry(score, nameBuf, nameIdx);
}
if (dir == DIR_NONE) lastNameDir = DIR_NONE;
// Button: confirm current char, advance
if (input.joyPressed()) {
if (++nameIdx >= 3) {
saveName();
return;
}
renderer.drawNameEntry(score, nameBuf, nameIdx);
}
// START: done editing
if (input.startPressed()) {
saveName();
}
}
void saveName() {
lb.insert(score, nameBuf);
newHS = true;
audioHigh();
st = ST_OVER;
lastMove = millis();
uint16_t timeSec = statsStartMs ? (millis() - statsStartMs) / 1000 : 0;
renderer.drawGameOver(score, newHS, statsFood, timeSec);
}
private:
void endGame() {
newHS = lb.isHighScore(score);
if (newHS) {
// Init name entry
nameBuf[0] = 'A'; nameBuf[1] = 'A'; nameBuf[2] = 'A'; nameBuf[3] = '\0';
nameIdx = 0;
lastNameTime = 0;
lastNameDir = DIR_NONE;
st = ST_NAME;
renderer.drawNameEntry(score, nameBuf, nameIdx);
} else {
audioDie();
st = ST_OVER;
lastMove = millis();
uint16_t timeSec = statsStartMs ? (millis() - statsStartMs) / 1000 : 0;
renderer.drawGameOver(score, newHS, statsFood, timeSec);
}
}
};
// ============================================================
// MAIN
// ============================================================
Game game;
void setup() {
Serial.begin(115200);
randomSeed(analogRead(34));
game.init();
}
void loop() {
game.loop();
}