/* * ============================================================================= * Fab Academy 2026 — Week 15 — Yaroslav Artsishevskiy * LED COLOR SHOOTER GAME * ----------------------------------------------------------------------------- * Gameplay: * - Colored "enemies" appear at the END of the strip (LED 29) and march * toward the BEGINNING (LED 0), one step at a time. * - Six colored buttons in the browser. Click one to fire a bullet of * that color from LED 0 outward. * - When a bullet meets an enemy: * same color --> BOTH DESTROYED (a hit!) * different --> bullet keeps going (a miss) * - If any enemy reaches LED 0, the game is OVER. The strip flashes red * and a "Restart" button appears in the browser. * * Level 1 only: * - Enemies spawn every 2 seconds. * - Enemies move one step every 600 ms. * - Bullets move one step every 80 ms (much faster than enemies). * * Hardware: * 30 WS2812B LEDs on D0. 5V to 5V pad. GND to GND pad. * * Libraries: FastLED, WiFi, WebServer * ============================================================================= */ #include #include #include "Network.h" #include "WiFi.h" #include // ---- Strip ---- #define LED_PIN D0 #define NUM_LEDS 30 #define BRIGHTNESS 60 CRGB leds[NUM_LEDS]; // ---- Wi-Fi ---- NetworkUDP udp; WebServer server(80); const char* ssid = "YaroUDP"; const char* password = "12345678"; IPAddress local_IP(192, 168, 4, 1); IPAddress gateway(192, 168, 4, 1); IPAddress subnet(255, 255, 255, 0); const uint16_t udpPort = 1234; // ---- Color palette -------------------------------------------------------- // Six game colors. Each enemy and each bullet has a "colorId" 0..5 and we // match by ID, not by raw RGB. Comparing IDs is exact — comparing CRGB is // fragile because one wrong byte means no match. enum ColorId { COL_RED, COL_ORANGE, COL_YELLOW, COL_GREEN, COL_BLUE, COL_PURPLE, COL_NONE = -1 }; CRGB paletteRGB(int id) { switch (id) { case COL_RED: return CRGB::Red; case COL_ORANGE: return CRGB(255, 100, 0); case COL_YELLOW: return CRGB::Yellow; case COL_GREEN: return CRGB::Green; case COL_BLUE: return CRGB::Blue; case COL_PURPLE: return CRGB(150, 0, 200); } return CRGB::Black; } // ---- Bullets (fly from LED 0 toward LED NUM_LEDS-1) ----------------------- #define MAX_BULLETS 10 struct Bullet { int position; // -1 = empty slot int colorId; }; Bullet bullets[MAX_BULLETS]; const unsigned long BULLET_STEP_MS = 80; unsigned long lastBulletStep = 0; // ---- Enemies (march from LED NUM_LEDS-1 toward LED 0) --------------------- #define MAX_ENEMIES 8 struct Enemy { int position; // -1 = empty slot int colorId; }; Enemy enemies[MAX_ENEMIES]; const unsigned long ENEMY_STEP_MS = 600; // how fast enemies advance const unsigned long ENEMY_SPAWN_MS = 2000; // how often a new enemy appears unsigned long lastEnemyStep = 0; unsigned long lastEnemySpawn = 0; // ---- Game state ----------------------------------------------------------- bool gameOver = false; int score = 0; // ---- Helper functions ----------------------------------------------------- void fireBullet(int colorId) { if (gameOver) return; for (int i = 0; i < MAX_BULLETS; i++) { if (bullets[i].position < 0) { bullets[i].position = 0; bullets[i].colorId = colorId; return; } } } void spawnEnemy() { for (int i = 0; i < MAX_ENEMIES; i++) { if (enemies[i].position < 0) { enemies[i].position = NUM_LEDS - 1; enemies[i].colorId = random(0, 6); // random one of the six colors return; } } } void resetGame() { for (int i = 0; i < MAX_BULLETS; i++) bullets[i].position = -1; for (int i = 0; i < MAX_ENEMIES; i++) enemies[i].position = -1; gameOver = false; score = 0; FastLED.clear(); FastLED.show(); } // ---- The webpage ---------------------------------------------------------- // Six color buttons + a status line + a Restart button (hidden until game over). // JavaScript polls /state every 500 ms to know whether to show the Restart UI. const char HTML_PAGE[] PROGMEM = R"HTML( YaroShoot

Color Shoot

Score: 0
Game Over
)HTML"; // ---- HTTP handlers -------------------------------------------------------- void handleRoot() { server.send_P(200, "text/html", HTML_PAGE); } void handleShoot() { int c = server.arg("c").toInt(); if (c >= 0 && c <= 5) fireBullet(c); server.send(200, "text/plain", "OK"); } void handleReset() { resetGame(); server.send(200, "text/plain", "OK"); } // Returns "0,42" or "1,17" — game-over flag + current score. void handleState() { String s = String(gameOver ? 1 : 0) + "," + String(score); server.send(200, "text/plain", s); } // ---- Setup ---------------------------------------------------------------- void setup() { Serial.begin(115200); delay(500); FastLED.addLeds(leds, NUM_LEDS); FastLED.setBrightness(BRIGHTNESS); FastLED.clear(); FastLED.show(); resetGame(); // initialize all slots and counters // ESP32-C3 has no analog noise pin, but micros() at boot is plenty random // for a game of this scale. randomSeed(micros()); Network.begin(); WiFi.mode(WIFI_AP); WiFi.softAPConfig(local_IP, gateway, subnet); WiFi.softAP(ssid, password); udp.begin(udpPort); server.on("/", handleRoot); server.on("/shoot", handleShoot); server.on("/reset", handleReset); server.on("/state", handleState); server.begin(); Serial.println("Game ready"); Serial.print("AP IP: "); Serial.println(WiFi.softAPIP()); } // ---- Game logic helpers --------------------------------------------------- // Move enemies one step (called once per ENEMY_STEP_MS). // If any enemy reaches LED 0, the game is over. void stepEnemies() { for (int i = 0; i < MAX_ENEMIES; i++) { if (enemies[i].position >= 0) { enemies[i].position--; if (enemies[i].position <= 0) { // Enemy reached the danger zone — Game Over enemies[i].position = 0; // freeze it on LED 0 so it draws there gameOver = true; } } } } // Move bullets one step. After moving, check for collisions with enemies. void stepBullets() { for (int b = 0; b < MAX_BULLETS; b++) { if (bullets[b].position < 0) continue; bullets[b].position++; // If gone past the end with no hit, just disappear if (bullets[b].position >= NUM_LEDS) { bullets[b].position = -1; continue; } // Collision check: is any enemy on the same LED as this bullet? for (int e = 0; e < MAX_ENEMIES; e++) { if (enemies[e].position == bullets[b].position) { if (enemies[e].colorId == bullets[b].colorId) { // Match! Both vanish, score goes up. enemies[e].position = -1; bullets[b].position = -1; score++; } // If colors don't match, the bullet passes through silently — // we do nothing here, the bullet keeps going. break; } } } } // ---- Drawing -------------------------------------------------------------- // Each frame we redraw the whole strip from scratch. // Order matters: bullets first, then enemies on top, so the player can // always see what color is approaching. void drawFrame() { FastLED.clear(); for (int b = 0; b < MAX_BULLETS; b++) { if (bullets[b].position >= 0 && bullets[b].position < NUM_LEDS) { leds[bullets[b].position] = paletteRGB(bullets[b].colorId); } } for (int e = 0; e < MAX_ENEMIES; e++) { if (enemies[e].position >= 0 && enemies[e].position < NUM_LEDS) { leds[enemies[e].position] = paletteRGB(enemies[e].colorId); } } FastLED.show(); } // Game over visual: flash the whole strip red a few times. void drawGameOver() { static unsigned long lastFlash = 0; static bool on = false; if (millis() - lastFlash >= 300) { lastFlash = millis(); on = !on; fill_solid(leds, NUM_LEDS, on ? CRGB::Red : CRGB::Black); FastLED.show(); } } // ---- Loop ----------------------------------------------------------------- void loop() { server.handleClient(); if (gameOver) { // Frozen game — only the flashing animation runs. drawGameOver(); return; } unsigned long now = millis(); // 1. Spawn a new enemy every ENEMY_SPAWN_MS if (now - lastEnemySpawn >= ENEMY_SPAWN_MS) { lastEnemySpawn = now; spawnEnemy(); } // 2. Move enemies on their slow tick if (now - lastEnemyStep >= ENEMY_STEP_MS) { lastEnemyStep = now; stepEnemies(); } // 3. Move bullets and check collisions on their fast tick if (now - lastBulletStep >= BULLET_STEP_MS) { lastBulletStep = now; stepBullets(); } // 4. Redraw the strip every loop iteration. FastLED is fast enough that // this is fine, and it keeps animation smooth. drawFrame(); }