Ah yes, the trials and tribulations of embedded programming. How fared octoabby this week...?
So, for my little device, I first had the idea of making a very simple game because I have 0 programming experience.
The idea was to have one button be the winner button, and the rest would say "nope!". I began attempting this,
but then realized it would be very boring and predictable.
And we simply must not be boring!
So to be slightly less boring, I wanted to randomize where the winner button would be.
Here is a video of the game!
#include < Wire.h >
#include < Adafruit_GFX.h >
#include < Adafruit_SSD1306.h >
#include < avr/pgmspace.h >
// ---------------- OLED ----------------
#define SCREEN_WIDTH 128
#define SCREEN_HEIGHT 64
#define SCREEN_ADDRESS 0x3C
Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, -1, 1000000UL, 1000000UL);
// ---------------- RGB LED ----------------
#define PIN_RED 17
#define PIN_GREEN 16
#define PIN_BLUE 25
// ---------------- Touch config ----------------
#define N_TOUCH 6
#define THRESHOLD 30 // base threshold; we add hysteresis around this
int touch_pins[N_TOUCH] = {3, 4, 2, 27, 1, 26};
int touch_values[N_TOUCH] = {0, 0, 0, 0, 0, 0}; // smoothed ints (for Serial debug)
bool pin_touched_now[N_TOUCH] = {false, false, false, false, false, false};
bool pin_touched_past[N_TOUCH] = {false, false, false, false, false, false};
// --------- Stability ---------
// Hysteresis (two thresholds)
const int THRESH_ON = THRESHOLD + 6; // must exceed this to turn ON
const int THRESH_OFF = THRESHOLD - 6; // must go below this to turn OFF
// Debounce (consecutive loops to confirm change)
const int ON_COUNT_REQUIRED = 3;
const int OFF_COUNT_REQUIRED = 3;
// Smoothing (exponential moving average)
const float FILTER_ALPHA = 0.40f; // 0..1; higher = snappier, lower = smoother
int touch_raw[N_TOUCH] = {0,0,0,0,0,0};
float touch_filtered[N_TOUCH] = {0,0,0,0,0,0};
int on_count[N_TOUCH] = {0,0,0,0,0,0};
int off_count[N_TOUCH] = {0,0,0,0,0,0};
// ---------------- UI Mode (optional, prevents redraw spam) ----------------
enum UiMode { UI_IDLE, UI_SHOW_PAD };
UiMode uiMode = UI_IDLE;
int uiPadIndex = -1;
// ---------------- Game state ----------------
enum GameState { GAME_WAITING, GAME_SHOW_RESULT };
GameState game = GAME_WAITING;
// --- You Win ---
bool lastResultWasWin = false; // remember if result was a win
unsigned long resultShownAt = 0; // when we drew the result screen
const unsigned long WIN_DISPLAY_MS = 4000; // how long to hold "You win!"
// --- hearts ---
#define HEART_W 8
#define HEART_H 7
const unsigned char PROGMEM HEART_BMP[] = {
0b01100110, //
0b11111111, //
0b11111111, //
0b11111111, //
0b01111110, //
0b00111100, //
0b00011000 //
};
// heart positions
const int HEART_COUNT = 4;
int heartX[HEART_COUNT] = {20, 92, 40, 70};
int heartY[HEART_COUNT] = {10, 10, 42, 42};
// Randomized "Nope" messages
const char* NOPE_MESSAGES[] = {
"Nope!",
"Try again.",
"Not quite...",
"No...",
"Not me :P",
"Mwahaha NO."
};
const int N_NOPE_MSG = sizeof(NOPE_MESSAGES)/sizeof(NOPE_MESSAGES[0]);
// ---------------- Helpers ----------------
void centerText(const char* msg, int y) {
int16_t x1, y1;
uint16_t w, h;
display.getTextBounds((char*)msg, 0, y, &x1, &y1, &w, &h);
int x = (SCREEN_WIDTH - (int)w) / 2;
display.setCursor(x, y);
display.print(msg);
}
void print_touch_serial() {
for (int i = 0; i < N_TOUCH; i++) {
char buf[8];
snprintf(buf, sizeof(buf), "%4d", touch_values[i]);
Serial.print(buf);
Serial.print(i == N_TOUCH - 1 ? '\n' : ' ');
}
}
void pickNewTarget() {
int old = targetPad;
if (N_TOUCH <= 1) { targetPad = 0; return; }
do {
targetPad = random(0, N_TOUCH);
} while (targetPad == old); // avoid same pad twice in a row
}
void drawBitmapCentered(const unsigned char* bmp, int bw, int bh, int yTop) {
int x = (SCREEN_WIDTH - bw) / 2;
display.drawBitmap(x, yTop, bmp, bw, bh, SSD1306_WHITE);
}
// Stability-upgraded touch read: discharge, measure rise time, smooth, hysteresis+debounce
void update_touch() {
const int t_max = 200;
for (int i = 0; i < N_TOUCH; i++) {
int p = touch_pins[i];
// 1) Discharge pad
pinMode(p, OUTPUT);
digitalWrite(p, LOW);
delayMicroseconds(40); // slightly longer discharge for stability
// 2) Time the rise via internal pull-up (capacitance slows the rise)
noInterrupts();
pinMode(p, INPUT_PULLUP);
int t = 0;
while (!digitalRead(p) && t < t_max) {
t++;
}
interrupts();
// 3) Smoothing
touch_raw[i] = t;
touch_filtered[i] = (FILTER_ALPHA * touch_raw[i]) +
((1.0f - FILTER_ALPHA) * touch_filtered[i]);
// 4) Hysteresis + debounce to produce stable ON/OFF
bool was = pin_touched_now[i];
bool now = was;
if (!was) { // currently OFF -> look for ON
if (touch_filtered[i] > THRESH_ON) {
on_count[i]++;
off_count[i] = 0;
if (on_count[i] >= ON_COUNT_REQUIRED) {
now = true;
on_count[i] = 0;
}
} else {
on_count[i] = 0;
off_count[i] = 0;
}
} else { // currently ON -> look for OFF
if (touch_filtered[i] < THRESH_OFF) {
off_count[i]++;
on_count[i] = 0;
if (off_count[i] >= OFF_COUNT_REQUIRED) {
now = false;
off_count[i] = 0;
}
} else {
on_count[i] = 0;
off_count[i] = 0;
}
}
pin_touched_past[i] = was;
pin_touched_now[i] = now;
// For Serial debug: store smoothed integer
touch_values[i] = (int)(touch_filtered[i] + 0.5f);
}
}
// Any pad currently pressed?
bool anyPadPressed() {
for (int i = 0; i < N_TOUCH; i++) if (pin_touched_now[i]) return true;
return false;
}
// Return first pad that was JUST pressed this frame, or -1
int firstJustPressed() {
for (int i = 0; i < N_TOUCH; i++) {
if (pin_touched_now[i] && !pin_touched_past[i]) return i;
}
return -1;
}
// ---------------- OLED screens ----------------
void showIdlePrompt() {
display.clearDisplay();
display.setTextSize(1);
// Leave vertical room for future icons (top and bottom)
centerText("Touch the", 18);
centerText("correct pad!", 30);
display.display();
}
void showWinScreen() {
display.clearDisplay();
// Centered text
display.setTextSize(1);
centerText("You win!", 28);
// Animate hearts: bounce gently up and down
unsigned long t = millis() / 200; // controls speed
for (int i = 0; i < HEART_COUNT; i++) {
int wobble = ((t + i) % 2 == 0) ? 0 : 1; // bob between y and y+1
display.drawBitmap(heartX[i], heartY[i] + wobble,
HEART_BMP, HEART_W, HEART_H, SSD1306_WHITE);
}
display.display();
}
void showNopeScreen(const char* msg) {
display.clearDisplay();
display.setTextSize(1);
centerText(msg, 20);
display.display();
}
// ---------------- SETUP ----------------
void setup() {
Serial.begin(115200);
// Optional LED pins
pinMode(PIN_RED, OUTPUT);
pinMode(PIN_GREEN, OUTPUT);
pinMode(PIN_BLUE, OUTPUT);
digitalWrite(PIN_RED, HIGH);
digitalWrite(PIN_GREEN, HIGH);
digitalWrite(PIN_BLUE, HIGH);
// OLED
display.begin(SSD1306_SWITCHCAPVCC, SCREEN_ADDRESS);
display.clearDisplay();
display.setTextSize(1);
display.setTextColor(SSD1306_WHITE);
display.display();
// Touch pins safe state when idle
for (int i = 0; i < N_TOUCH; i++) {
pinMode(touch_pins[i], INPUT_PULLUP);
randomSeed(analogRead(A0) ^ micros());
}
// Start state
uiMode = UI_IDLE;
uiPadIndex = -1;
game = GAME_WAITING;
targetPad = 0; // Phase 3: fixed target is pad 0
showIdlePrompt();
}
// ---------------- LOOP ----------------
void loop() {
update_touch();
print_touch_serial(); // debug values
int pressed = firstJustPressed(); // edge: just pressed this frame
if (game == GAME_WAITING) {
if (pressed != -1) {
uiPadIndex = pressed; // for "Not pad X" hint on miss
if (pressed == targetPad) {
// Correct
showWinScreen(); // draw once immediately
digitalWrite(PIN_GREEN, LOW); // green on
digitalWrite(PIN_RED, HIGH);
lastResultWasWin = true;
resultShownAt = millis();
game = GAME_SHOW_RESULT;
} else {
// Wrong
int idx = random(N_NOPE_MSG);
showNopeScreen(NOPE_MESSAGES[idx]);
digitalWrite(PIN_GREEN, HIGH);
digitalWrite(PIN_RED, LOW); // red on
lastResultWasWin = false;
game = GAME_SHOW_RESULT;
}
}
} else if (game == GAME_SHOW_RESULT) {
if (lastResultWasWin) {
// Re-render animated win screen every frame (for twinkle/hearts)
showWinScreen();
if (millis() - resultShownAt >= WIN_DISPLAY_MS) {
pickNewTarget(); // new correct pad next round
digitalWrite(PIN_GREEN, HIGH);
digitalWrite(PIN_RED, HIGH);
showIdlePrompt();
game = GAME_WAITING;
}
} else {
// On loss, wait for release, then return
if (!anyPadPressed()) {
digitalWrite(PIN_GREEN, HIGH);
digitalWrite(PIN_RED, HIGH);
showIdlePrompt();
game = GAME_WAITING;
}
}
}
delay(50); // keep this INSIDE loop(), at the very end
}
My sketch starts with libraries that I took from Quentin's examples.
Then the OLED, RBG, and touch config parts have the OLED screen settings, LED pins, and touch pads. 6 pins are in an array so the code can loop and check for touches.
The touch pads are read by charging/discharging the pad and timing how long it takes to change the state, so to make it more reliable, I added filtering (smooths noisy readings), hysteresis (requires it to go clearly above/below thresholds to flip state), and debouncing (needs several consistent readings before it confirms a press/release). I added these because it was super flicker-y at first.
The game then is structured around two states: Waiting ("choose the correct pad!"") and showing result ("you win!" or "nope"). It uses timers (millis()) to hold the win screen before resetting and choosing a new winner button.
I also wanted to add some little hearts for fun so here they are.
I also did not want every button to say the same "nope" message every time, so this is a list of my randomized "nope" messages.
Here are the display helpers that handle drawing: centerText()
prints the text centered,
print_touch_serial
shows values in the serial monitor,
pickNewTarget()
picks the next random winner button,
drawBitmapCentered
is for the heart placement,
This is for touch input and stability,
showIdlePrompt()
is the start screen,
showWinScreen()
shows "You win!" with the little hearts,
This is to animate the hearts.
showNopeScreen()
prints random "nope" message.
Setup runs once when the board is powered on. It prepares the hardware and sets the starting state.>
The LEDs are configured as outputs and the OLED screen is set up.
The touch pins are set to default and the random number generator is seeded so the winner pad changes between runs.
The game is then in waiting mode and shows the prompt "Touch the correct pad!"
The loop runs forever after setup()
and this is where the game happens.
If waiting for a press: GAME_WAITING. If you press the correct one, "You win!"" screen shows and LED turns green - setting state to show result. If you press the wrong pad, a random "Nope!" message appears, LED turns red, setting state to show result.
If showing result: GAME_SHOW_RESULT. If it was a win, keep redrawing the win screen so the hearts animate. After 4s, pick a new random correct pad, reset LED, and return to idle prompt. If it was a loss, keep showing nope screen until pad is released. Once released, reset LED and show idle prompt.
Following a similar logic of the randomized phrases in my previous game, I thought it would be fun to make an 8 Ball of sorts.
I also got the old board to work again!
Using a list of 8-Ball responses:
"It is certain.",
"It is decidedly so.",
"Without a doubt.",
"Yes, definitely.",
"You may rely on it.",
"As I see it, yes.",
"Most likely.",
"Outlook good.",
"Yes.",
"Signs point to yes.",
"Reply hazy, try again.",
"Ask again later.",
"Better not tell you now.",
"Cannot predict now.",
"Concentrate and ask again.",
"Don't count on it.",
"My reply is no.",
"My sources say no.",
"Outlook not so good.",
"Very doubtful."
I randomized the response for each button.
Here is the code:
#include < Wire.h >
#include < Adafruit_GFX.h >
#include < Adafruit_SSD1306.h >
// ========================= OLED =========================
#define SCREEN_WIDTH 128
#define SCREEN_HEIGHT 64
#define SCREEN_ADDRESS 0x3C
Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, -1, 1000000UL, 1000000UL);
// ========================= Optional RGB LED =========================
#define PIN_RED 17
#define PIN_GREEN 16
#define PIN_BLUE 25
// ========================= Touch Config =========================
#define N_TOUCH 6
#define THRESHOLD 30 // base threshold; hysteresis will widen around this
int touch_pins[N_TOUCH] = {3, 4, 2, 27, 1, 26};
int touch_values[N_TOUCH] = {0, 0, 0, 0, 0, 0}; // smoothed values for Serial debug
bool pin_touched_now[N_TOUCH] = {false, false, false, false, false, false};
bool pin_touched_past[N_TOUCH] = {false, false, false, false, false, false};
// ---- Stability (Phase 2.1) ----
// Hysteresis
const int THRESH_ON = THRESHOLD + 6; // exceed to turn ON
const int THRESH_OFF = THRESHOLD - 6; // go below to turn OFF
// Debounce (consecutive loops)
const int ON_COUNT_REQUIRED = 3;
const int OFF_COUNT_REQUIRED = 3;
// Smoothing
const float FILTER_ALPHA = 0.40f; // 0..1, higher = snappier
int touch_raw[N_TOUCH] = {0,0,0,0,0,0};
float touch_filtered[N_TOUCH] = {0,0,0,0,0,0};
int on_count[N_TOUCH] = {0,0,0,0,0,0};
int off_count[N_TOUCH] = {0,0,0,0,0,0};
// --- Magic 8-Ball phrases (grouped) ---
const char* PHRASES[] = {
// Affirmative (10)
"It is certain.",
"It is decidedly so.",
"Without a doubt.",
"Yes, definitely.",
"You may rely on it.",
"As I see it, yes.",
"Most likely.",
"Outlook good.",
"Yes.",
"Signs point to yes.",
// Non-committal (5)
"Reply hazy, try again.",
"Ask again later.",
"Better not tell you now.",
"Cannot predict now.",
"Concentrate and ask again.",
// Negative (5)
"Don't count on it.",
"My reply is no.",
"My sources say no.",
"Outlook not so good.",
"Very doubtful."
};
const int N_PHRASES = sizeof(PHRASES) / sizeof(PHRASES[0]);
// ========================= Mode / Timing =========================
enum Mode { MODE_WAITING, MODE_SHOWING };
Mode mode = MODE_WAITING;
unsigned long phraseShownAt = 0;
const unsigned long PHRASE_DISPLAY_MS = 5000; // how long to keep phrase visible
// Store which phrase we’re showing (for non-blocking redraw if needed)
int currentPhraseIdx = -1;
// ========================= Helpers =========================
// Center one line of text at y
void centerText(const char* msg, int y) {
int16_t x1, y1;
uint16_t w, h;
display.getTextBounds((char*)msg, 0, y, &x1, &y1, &w, &h);
int x = (SCREEN_WIDTH - (int)w) / 2;
display.setCursor(x, y);
display.print(msg);
}
// Very simple word-wrap + centered lines.
// Draws multiple lines starting at yStart, with 'lineGap' pixels between lines.
void drawWrappedCentered(const char* msg, int yStart, int lineGap = 10) {
// We’ll build lines greedily. Using Arduino String for simplicity.
String text(msg);
String line = "";
int y = yStart;
// Break into words
int start = 0;
while (start < (int)text.length()) {
// find next space
int spacePos = text.indexOf(' ', start);
String word;
if (spacePos == -1) {
word = text.substring(start);
start = text.length();
} else {
word = text.substring(start, spacePos);
start = spacePos + 1;
}
// Try adding this word to current line
String trial = line.length() ? (line + " " + word) : word;
int16_t x1, y1;
uint16_t w, h;
display.getTextBounds((char*)trial.c_str(), 0, y, &x1, &y1, &w, &h);
if (w <= SCREEN_WIDTH - 2) {
line = trial; // fits
} else {
// draw current line centered
centerText(line.c_str(), y);
y += lineGap;
line = word; // start new line with this word
}
}
// draw the last line if any
if (line.length()) {
centerText(line.c_str(), y);
}
}
void print_touch_serial() {
for (int i = 0; i < N_TOUCH; i++) {
char buf[8];
snprintf(buf, sizeof(buf), "%4d", touch_values[i]);
Serial.print(buf);
Serial.print(i == N_TOUCH - 1 ? '\n' : ' ');
}
}
// Stable touch update: discharge, time rise, smooth, hysteresis+debounce
void update_touch() {
const int t_max = 200;
for (int i = 0; i < N_TOUCH; i++) {
int p = touch_pins[i];
// 1) Discharge pad
pinMode(p, OUTPUT);
digitalWrite(p, LOW);
delayMicroseconds(40);
// 2) Time the rise via pull-up (capacitance slows the rise)
noInterrupts();
pinMode(p, INPUT_PULLUP);
int t = 0;
while (!digitalRead(p) && t < t_max) {
t++;
}
interrupts();
// 3) Smoothing
touch_raw[i] = t;
touch_filtered[i] = (FILTER_ALPHA * touch_raw[i]) +
((1.0f - FILTER_ALPHA) * touch_filtered[i]);
// 4) Hysteresis + debounce
bool was = pin_touched_now[i];
bool now = was;
if (!was) { // OFF -> look for ON
if (touch_filtered[i] > THRESH_ON) {
on_count[i]++; off_count[i] = 0;
if (on_count[i] >= ON_COUNT_REQUIRED) { now = true; on_count[i] = 0; }
} else { on_count[i] = 0; off_count[i] = 0; }
} else { // ON -> look for OFF
if (touch_filtered[i] < THRESH_OFF) {
off_count[i]++; on_count[i] = 0;
if (off_count[i] >= OFF_COUNT_REQUIRED) { now = false; off_count[i] = 0; }
} else { on_count[i] = 0; off_count[i] = 0; }
}
pin_touched_past[i] = was;
pin_touched_now[i] = now;
touch_values[i] = (int)(touch_filtered[i] + 0.5f); // for Serial debug
}
}
// Edge: returns first pad that was just pressed (now true, past false), or -1
int firstJustPressed() {
for (int i = 0; i < N_TOUCH; i++) {
if (pin_touched_now[i] && !pin_touched_past[i]) return i;
}
return -1;
}
// Screens
void showPrompt() {
display.clearDisplay();
display.setTextSize(1);
centerText("Ask a question...", 10);
centerText("Touch any pad", 28);
centerText("for an answer...", 46);
display.display();
}
void showPhrase(const char* msg) {
display.clearDisplay();
display.setTextSize(1);
// Start a bit higher so 2–3 lines fit nicely
drawWrappedCentered(msg, 20, 10);
display.display();
}
// ========================= Setup / Loop =========================
void setup() {
Serial.begin(115200);
// Optional LED pins (ignore if not connected)
pinMode(PIN_RED, OUTPUT);
pinMode(PIN_GREEN, OUTPUT);
pinMode(PIN_BLUE, OUTPUT);
digitalWrite(PIN_RED, HIGH);
digitalWrite(PIN_GREEN, HIGH);
digitalWrite(PIN_BLUE, HIGH);
// OLED
display.begin(SSD1306_SWITCHCAPVCC, SCREEN_ADDRESS);
display.clearDisplay();
display.setTextSize(1);
display.setTextColor(SSD1306_WHITE);
display.display();
// Touch pins default to INPUT_PULLUP
for (int i = 0; i < N_TOUCH; i++) {
pinMode(touch_pins[i], INPUT_PULLUP);
}
// Seed randomness (use any floating analog or micros() as entropy)
randomSeed(analogRead(A0) ^ micros());
mode = MODE_WAITING;
currentPhraseIdx = -1;
showPrompt();
}
void loop() {
update_touch();
print_touch_serial();
if (mode == MODE_WAITING) {
int pressed = firstJustPressed();
if (pressed != -1) {
// Pick a random phrase and show it
currentPhraseIdx = random(0, N_PHRASES);
showPhrase(PHRASES[currentPhraseIdx]);
// Optional LED blink: green for a beat
digitalWrite(PIN_GREEN, LOW);
digitalWrite(PIN_RED, HIGH);
phraseShownAt = millis();
mode = MODE_SHOWING;
}
}
else if (mode == MODE_SHOWING) {
// Keep phrase up until timer expires, then return to prompt
if (millis() - phraseShownAt >= PHRASE_DISPLAY_MS) {
digitalWrite(PIN_GREEN, HIGH);
digitalWrite(PIN_RED, HIGH);
showPrompt();
mode = MODE_WAITING;
}
}
delay(50); // ~20 FPS pacing; pairs nicely with debounce settings
}