Initial implementation of HeisserDraht buzz wire game

ESP32-C3 based hot wire game with WS2812B LED effects,
SSD1306 OLED display, debounced touch detection, and
full game state machine (IDLE → COUNTDOWN → PLAYING → GAME_OVER).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-22 00:24:52 +01:00
commit 01e79a4eb2
9 changed files with 619 additions and 0 deletions

20
.gitignore vendored Normal file
View File

@@ -0,0 +1,20 @@
# Arduino build artifacts
build/
*.elf
*.bin
*.hex
*.map
# IDE files
.vscode/
*.code-workspace
.idea/
# OS files
.DS_Store
Thumbs.db
# Backup files
*~
*.bak
*.swp

54
HeisserDraht.ino Normal file
View File

@@ -0,0 +1,54 @@
/*
* HeisserDraht - ESP32-C3 Buzz Wire Game
*
* Hardware:
* - ESP32-C3 (RISC-V)
* - 16x WS2812B LEDs on GPIO 8
* - SSD1306 128x64 OLED on I2C (SDA=4, SCL=5)
* - Wire touch sensor on GPIO 2 (INPUT_PULLUP, touch = GND)
* - Start button on GPIO 3 (INPUT_PULLUP, press = GND)
* - Optional passive buzzer on GPIO 10
*
* Game flow: IDLE → COUNTDOWN → PLAYING → GAME_OVER → IDLE
*/
#include "config.h"
#include "game_logic.h"
#include "led_effects.h"
#include "display.h"
void setup() {
Serial.begin(115200);
delay(500);
Serial.println();
Serial.println("=== HEISSER DRAHT " VERSION_STR " ===");
Serial.println("[INIT] Starting...");
// Initialize subsystems
gameInit();
Serial.println("[INIT] Game logic OK");
ledsInit();
Serial.println("[INIT] LEDs OK");
displayInit();
Serial.println("[INIT] Display OK");
Serial.println("[INIT] Ready! Press START to begin.");
}
void loop() {
// Check inputs
gameCheckStartButton();
gameCheckTouch();
// Update game state
gameUpdate();
// Update outputs
ledsUpdate();
displayUpdate();
// Small delay for watchdog
delay(LOOP_DELAY_MS);
}

50
config.h Normal file
View File

@@ -0,0 +1,50 @@
#ifndef CONFIG_H
#define CONFIG_H
// ── Pin Assignments ─────────────────────────────────────────────────
#define PIN_NEOPIXEL 8 // WS2812B data (330Ω series resistor recommended)
#define PIN_SDA 4 // I2C SDA (OLED)
#define PIN_SCL 5 // I2C SCL (OLED)
#define PIN_WIRE_TOUCH 2 // Wire touch detect (INPUT_PULLUP, loop-to-GND)
#define PIN_START_BTN 3 // Start button (INPUT_PULLUP, button-to-GND)
#define PIN_BUZZER 10 // Optional passive piezo buzzer
// ── LED Configuration ───────────────────────────────────────────────
#define NUM_LEDS 16 // Number of WS2812B LEDs (adjustable 10-20)
#define LED_BRIGHTNESS 60 // Default brightness (0-255)
// ── OLED Configuration ──────────────────────────────────────────────
#define SCREEN_WIDTH 128
#define SCREEN_HEIGHT 64
#define OLED_ADDR 0x3C
#define DISPLAY_UPDATE_MS 150 // Throttle display updates to avoid I2C flicker
// ── Game Timing ─────────────────────────────────────────────────────
#define GAME_DURATION_S 60 // Game duration in seconds
#define COUNTDOWN_SECS 3 // Countdown before game starts
#define MAX_STRIKES 10 // Maximum strikes before game over
// ── Debounce ────────────────────────────────────────────────────────
#define TOUCH_DEBOUNCE_MS 200 // Touch detection debounce
#define BUTTON_DEBOUNCE_MS 50 // Start button debounce
// ── Scoring ─────────────────────────────────────────────────────────
#define SCORE_BASE 1000 // Starting score
#define SCORE_STRIKE_PENALTY 50 // Points lost per strike
#define SCORE_TIME_DIVISOR 100 // elapsed_ms / divisor = time penalty
// ── Buzzer Tones ────────────────────────────────────────────────────
#define TONE_COUNTDOWN_HZ 800
#define TONE_GO_HZ 1200
#define TONE_TOUCH_HZ 1000
#define TONE_WIN_HZ 1500
#define TONE_LOSE_HZ 400
#define TONE_DURATION_MS 150
// ── Loop Timing ─────────────────────────────────────────────────────
#define LOOP_DELAY_MS 10 // Main loop delay for watchdog
// ── Version ─────────────────────────────────────────────────────────
#define VERSION_STR "v1.0"
#endif // CONFIG_H

164
display.cpp Normal file
View File

@@ -0,0 +1,164 @@
#include "display.h"
#include "config.h"
#include "game_logic.h"
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
static Adafruit_SSD1306 oled(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, -1);
static unsigned long lastUpdateMs = 0;
// ── Screens ─────────────────────────────────────────────────────────
static void screenSplash() {
oled.clearDisplay();
oled.setTextColor(SSD1306_WHITE);
oled.setTextSize(2);
oled.setCursor(8, 10);
oled.print("HEISSER");
oled.setCursor(20, 30);
oled.print("DRAHT");
oled.setTextSize(1);
oled.setCursor(48, 52);
oled.print(VERSION_STR);
oled.display();
}
static void screenIdle() {
oled.clearDisplay();
oled.setTextColor(SSD1306_WHITE);
oled.setTextSize(2);
oled.setCursor(8, 5);
oled.print("HEISSER");
oled.setCursor(20, 23);
oled.print("DRAHT");
// Blinking "Druecke START"
if ((millis() / 500) % 2 == 0) {
oled.setTextSize(1);
oled.setCursor(12, 50);
oled.print("Druecke START");
}
oled.display();
}
static void screenCountdown() {
GameData& gd = gameGetData();
oled.clearDisplay();
oled.setTextColor(SSD1306_WHITE);
if (gd.countdownValue > 0) {
oled.setTextSize(4);
oled.setCursor(52, 16);
oled.print(gd.countdownValue);
} else {
oled.setTextSize(3);
oled.setCursor(36, 20);
oled.print("GO!");
}
oled.display();
}
static void screenPlaying() {
GameData& gd = gameGetData();
oled.clearDisplay();
oled.setTextColor(SSD1306_WHITE);
// Timer MM:SS
unsigned long remainMs = 0;
unsigned long totalMs = (unsigned long)GAME_DURATION_S * 1000UL;
if (gd.elapsedMs < totalMs) remainMs = totalMs - gd.elapsedMs;
uint8_t mins = (remainMs / 1000) / 60;
uint8_t secs = (remainMs / 1000) % 60;
oled.setTextSize(1);
oled.setCursor(0, 0);
oled.print("Zeit: ");
if (mins < 10) oled.print('0');
oled.print(mins);
oled.print(':');
if (secs < 10) oled.print('0');
oled.print(secs);
// Strikes
oled.setCursor(0, 14);
oled.print("Fehler: ");
oled.print(gd.strikes);
oled.print('/');
oled.print(MAX_STRIKES);
// Score (large)
oled.setTextSize(3);
oled.setCursor(10, 34);
oled.print(gd.score);
oled.display();
}
static void screenGameOver() {
GameData& gd = gameGetData();
oled.clearDisplay();
oled.setTextColor(SSD1306_WHITE);
oled.setTextSize(1);
oled.setCursor(20, 0);
oled.print(gd.won ? "GESCHAFFT!" : "GAME OVER!");
// Final score
oled.setTextSize(2);
oled.setCursor(10, 14);
oled.print("Score:");
oled.print(gd.score);
// Stats
oled.setTextSize(1);
unsigned long totalSec = gd.elapsedMs / 1000;
oled.setCursor(0, 36);
oled.print("Zeit: ");
oled.print(totalSec);
oled.print("s Fehler: ");
oled.print(gd.strikes);
// Restart hint (blinking)
if ((millis() / 600) % 2 == 0) {
oled.setCursor(8, 54);
oled.print("START = Nochmal");
}
oled.display();
}
// ── Public API ──────────────────────────────────────────────────────
void displayInit() {
Wire.begin(PIN_SDA, PIN_SCL);
if (!oled.begin(SSD1306_SWITCHCAPVCC, OLED_ADDR)) {
Serial.println("[OLED] Init FAILED");
return;
}
Serial.println("[OLED] Init OK");
screenSplash();
delay(1500);
}
void displayUpdate() {
unsigned long now = millis();
if (now - lastUpdateMs < DISPLAY_UPDATE_MS) return;
lastUpdateMs = now;
GameData& gd = gameGetData();
switch (gd.state) {
case STATE_IDLE: screenIdle(); break;
case STATE_COUNTDOWN: screenCountdown(); break;
case STATE_PLAYING: screenPlaying(); break;
case STATE_GAME_OVER: screenGameOver(); break;
}
}

9
display.h Normal file
View File

@@ -0,0 +1,9 @@
#ifndef DISPLAY_H
#define DISPLAY_H
#include <Arduino.h>
void displayInit();
void displayUpdate(); // Called every loop, dispatches based on game state (throttled)
#endif // DISPLAY_H

142
game_logic.cpp Normal file
View File

@@ -0,0 +1,142 @@
#include "game_logic.h"
#include "config.h"
#include "led_effects.h"
static GameData gd;
void gameInit() {
pinMode(PIN_WIRE_TOUCH, INPUT_PULLUP);
pinMode(PIN_START_BTN, INPUT_PULLUP);
pinMode(PIN_BUZZER, OUTPUT);
digitalWrite(PIN_BUZZER, LOW);
gd.state = STATE_IDLE;
gd.strikes = 0;
gd.score = SCORE_BASE;
gd.gameStartMs = 0;
gd.elapsedMs = 0;
gd.countdownValue = COUNTDOWN_SECS;
gd.countdownStepMs = 0;
gd.touchActive = false;
gd.lastTouchMs = 0;
gd.lastButtonMs = 0;
gd.won = false;
}
GameData& gameGetData() {
return gd;
}
void gameCheckStartButton() {
if (digitalRead(PIN_START_BTN) == LOW) {
unsigned long now = millis();
if (now - gd.lastButtonMs < BUTTON_DEBOUNCE_MS) return;
gd.lastButtonMs = now;
if (gd.state == STATE_IDLE || gd.state == STATE_GAME_OVER) {
// Reset game data
gd.strikes = 0;
gd.score = SCORE_BASE;
gd.elapsedMs = 0;
gd.countdownValue = COUNTDOWN_SECS;
gd.touchActive = false;
gd.won = false;
// Start countdown
gd.state = STATE_COUNTDOWN;
gd.countdownStepMs = millis();
Serial.println("[GAME] START pressed -> COUNTDOWN");
}
}
}
void gameCheckTouch() {
if (gd.state != STATE_PLAYING) return;
bool touching = (digitalRead(PIN_WIRE_TOUCH) == LOW);
unsigned long now = millis();
if (touching && !gd.touchActive) {
if (now - gd.lastTouchMs < TOUCH_DEBOUNCE_MS) return;
gd.lastTouchMs = now;
gd.touchActive = true;
gd.strikes++;
Serial.print("[GAME] TOUCH! Strikes: ");
Serial.println(gd.strikes);
// Buzzer alert
tone(PIN_BUZZER, TONE_TOUCH_HZ, TONE_DURATION_MS);
// Blocking LED flash
ledsTouchFlash();
// Check for game over by max strikes
if (gd.strikes >= MAX_STRIKES) {
gd.won = false;
gd.state = STATE_GAME_OVER;
// Lose tone
tone(PIN_BUZZER, TONE_LOSE_HZ, 500);
Serial.println("[GAME] MAX STRIKES -> GAME_OVER (lose)");
}
} else if (!touching) {
gd.touchActive = false;
}
}
void gameUpdate() {
unsigned long now = millis();
switch (gd.state) {
case STATE_IDLE:
// Nothing to update, waiting for start button
break;
case STATE_COUNTDOWN:
if (now - gd.countdownStepMs >= 1000) {
gd.countdownStepMs = now;
if (gd.countdownValue > 0) {
// Countdown beep
tone(PIN_BUZZER, TONE_COUNTDOWN_HZ, 100);
Serial.print("[GAME] Countdown: ");
Serial.println(gd.countdownValue);
gd.countdownValue--;
} else {
// GO!
tone(PIN_BUZZER, TONE_GO_HZ, 200);
gd.state = STATE_PLAYING;
gd.gameStartMs = millis();
Serial.println("[GAME] GO! -> PLAYING");
}
}
break;
case STATE_PLAYING:
gd.elapsedMs = now - gd.gameStartMs;
// Calculate live score
gd.score = SCORE_BASE
- (gd.strikes * SCORE_STRIKE_PENALTY)
- (int)(gd.elapsedMs / SCORE_TIME_DIVISOR);
if (gd.score < 0) gd.score = 0;
// Check time up
if (gd.elapsedMs >= (unsigned long)GAME_DURATION_S * 1000UL) {
gd.won = (gd.strikes < MAX_STRIKES);
gd.state = STATE_GAME_OVER;
if (gd.won) {
tone(PIN_BUZZER, TONE_WIN_HZ, 300);
Serial.println("[GAME] TIME UP -> GAME_OVER (win)");
} else {
tone(PIN_BUZZER, TONE_LOSE_HZ, 500);
Serial.println("[GAME] TIME UP -> GAME_OVER (lose)");
}
}
break;
case STATE_GAME_OVER:
// Waiting for start button to restart
break;
}
}

37
game_logic.h Normal file
View File

@@ -0,0 +1,37 @@
#ifndef GAME_LOGIC_H
#define GAME_LOGIC_H
#include <Arduino.h>
// Game states
enum GameState {
STATE_IDLE,
STATE_COUNTDOWN,
STATE_PLAYING,
STATE_GAME_OVER
};
// Game data
struct GameData {
GameState state;
uint8_t strikes;
int score;
unsigned long gameStartMs;
unsigned long elapsedMs;
uint8_t countdownValue; // 3, 2, 1, 0(GO!)
unsigned long countdownStepMs;
bool touchActive; // true while wire is being touched
unsigned long lastTouchMs;
unsigned long lastButtonMs;
bool won; // true if timer ran out with strikes < MAX
};
void gameInit();
void gameUpdate();
GameData& gameGetData();
// Called by main loop to check inputs
void gameCheckStartButton();
void gameCheckTouch();
#endif // GAME_LOGIC_H

132
led_effects.cpp Normal file
View File

@@ -0,0 +1,132 @@
#include "led_effects.h"
#include "config.h"
#include "game_logic.h"
#include <Adafruit_NeoPixel.h>
static Adafruit_NeoPixel strip(NUM_LEDS, PIN_NEOPIXEL, NEO_GRB + NEO_KHZ800);
// ── Helpers ─────────────────────────────────────────────────────────
static uint32_t colorWheel(uint8_t pos) {
pos = 255 - pos;
if (pos < 85) return strip.Color(255 - pos * 3, 0, pos * 3);
if (pos < 170) { pos -= 85; return strip.Color(0, pos * 3, 255 - pos * 3); }
pos -= 170;
return strip.Color(pos * 3, 255 - pos * 3, 0);
}
// ── Animations ──────────────────────────────────────────────────────
static void animIdle() {
// Rainbow chase
static uint16_t hue = 0;
for (int i = 0; i < NUM_LEDS; i++) {
strip.setPixelColor(i, colorWheel((i * 256 / NUM_LEDS + hue) & 0xFF));
}
hue += 2;
strip.show();
}
static void animCountdown() {
GameData& gd = gameGetData();
uint32_t color;
switch (gd.countdownValue) {
case 3: // fall through
case 2: color = strip.Color(255, 0, 0); break; // Red
case 1: color = strip.Color(255, 180, 0); break; // Yellow
default: color = strip.Color(0, 255, 0); break; // Green (GO!)
}
for (int i = 0; i < NUM_LEDS; i++) {
strip.setPixelColor(i, color);
}
strip.show();
}
static void animPlaying() {
GameData& gd = gameGetData();
unsigned long totalMs = (unsigned long)GAME_DURATION_S * 1000UL;
float progress = 1.0f - (float)gd.elapsedMs / (float)totalMs;
if (progress < 0.0f) progress = 0.0f;
int litLeds = (int)(progress * NUM_LEDS + 0.5f);
for (int i = 0; i < NUM_LEDS; i++) {
if (i < litLeds) {
// Color transitions: green → yellow → red as time depletes
if (progress > 0.5f) strip.setPixelColor(i, strip.Color(0, 255, 0));
else if (progress > 0.2f) strip.setPixelColor(i, strip.Color(255, 180, 0));
else strip.setPixelColor(i, strip.Color(255, 0, 0));
} else {
strip.setPixelColor(i, 0);
}
}
strip.show();
}
static void animGameOverWin() {
// Green/gold sparkle
static unsigned long lastSparkle = 0;
unsigned long now = millis();
if (now - lastSparkle > 80) {
lastSparkle = now;
for (int i = 0; i < NUM_LEDS; i++) {
if (random(3) == 0)
strip.setPixelColor(i, strip.Color(255, 215, 0)); // Gold
else
strip.setPixelColor(i, strip.Color(0, 180, 0)); // Green
}
strip.show();
}
}
static void animGameOverLose() {
// Pulsing red breathing
static uint8_t breathVal = 0;
static int8_t breathDir = 2;
breathVal += breathDir;
if (breathVal >= 250 || breathVal <= 5) breathDir = -breathDir;
for (int i = 0; i < NUM_LEDS; i++) {
strip.setPixelColor(i, strip.Color(breathVal, 0, 0));
}
strip.show();
}
// ── Public API ──────────────────────────────────────────────────────
void ledsInit() {
strip.begin();
strip.setBrightness(LED_BRIGHTNESS);
strip.show();
}
void ledsSetBrightness(uint8_t b) {
strip.setBrightness(b);
}
void ledsTouchFlash() {
// Blocking 3x red blink (~300ms total)
for (int flash = 0; flash < 3; flash++) {
for (int i = 0; i < NUM_LEDS; i++)
strip.setPixelColor(i, strip.Color(255, 0, 0));
strip.show();
delay(50);
for (int i = 0; i < NUM_LEDS; i++)
strip.setPixelColor(i, 0);
strip.show();
delay(50);
}
}
void ledsUpdate() {
GameData& gd = gameGetData();
switch (gd.state) {
case STATE_IDLE: animIdle(); break;
case STATE_COUNTDOWN: animCountdown(); break;
case STATE_PLAYING: animPlaying(); break;
case STATE_GAME_OVER:
if (gd.won) animGameOverWin();
else animGameOverLose();
break;
}
}

11
led_effects.h Normal file
View File

@@ -0,0 +1,11 @@
#ifndef LED_EFFECTS_H
#define LED_EFFECTS_H
#include <Arduino.h>
void ledsInit();
void ledsUpdate(); // Called every loop, dispatches based on game state
void ledsTouchFlash(); // Blocking 3x red blink (~300ms)
void ledsSetBrightness(uint8_t b);
#endif // LED_EFFECTS_H