/* * Two-Button Game Console (Doc TBGC-01 Rev A) * ESP32-C3 Super Mini + SSD1306 128x64 OLED (I2C) + 2 NO pushbuttons. * * Signal Pin Notes * --------------------------------------------------------------- * OLED SDA GPIO6 Wire.begin(6, 7) - matches board silkscreen * OLED SCL GPIO7 * BTN A GPIO3 to GND, INPUT_PULLUP, active LOW * BTN B GPIO4 to GND, INPUT_PULLUP, active LOW * OLED VCC 3V3 I2C address 0x3C * * Board: "ESP32C3 Dev Module", USB CDC On Boot: Enabled. * Libraries: Adafruit SSD1306, Adafruit GFX. * * Everything is a millis() state machine - no blocking waits once the * splash screen is done, so button polling stays responsive. */ #include #include #include #include // Persist the reaction-time best score in NVS. Set to 0 for RAM only. #define PERSIST_BEST 1 #if PERSIST_BEST #include #endif // ---------------------------------------------------------------- hardware static const uint8_t PIN_SDA = 6; static const uint8_t PIN_SCL = 7; static const uint8_t PIN_BTN_A = 3; static const uint8_t PIN_BTN_B = 4; static const uint8_t OLED_ADDR = 0x3C; static const int16_t SCREEN_W = 128; static const int16_t SCREEN_H = 64; static const int8_t OLED_RESET = -1; // sharing the ESP32 reset // ---------------------------------------------------------------- tunables static const uint32_t TARGET_MS = 10000; // Mode 1 goal static const uint32_t DEBOUNCE_MS = 25; static const uint32_t BLANK_AFTER_MS = 3000; // Mode 1: hide the clock static const uint32_t FRAME_MS = 33; // ~30 Hz live redraw static const uint32_t FALSE_START_MS = 1200; // Mode 2: message dwell static const uint32_t ARM_MIN_MS = 2000; // Mode 2: random wait window static const uint32_t ARM_MAX_MS = 5000; // Mode 1 rating tiers, in ms of absolute error. static const uint32_t TIER_PERFECT = 50; static const uint32_t TIER_GREAT = 150; static const uint32_t TIER_OK = 400; Adafruit_SSD1306 display(SCREEN_W, SCREEN_H, &Wire, OLED_RESET); #if PERSIST_BEST Preferences prefs; #endif // ----------------------------------------------------------------- buttons /* * Debounced polling with edge events. `ignoring` implements "swallow a * still-held button" - after a state change we suppress the button until the * player physically lets go, so the press that ended a game can't also * dismiss the result screen. */ class Button { public: void begin(uint8_t p) { pin = p; pinMode(pin, INPUT_PULLUP); bool raw = down(); lastRead = stable = raw; lastChangeMs = millis(); edgePressed = edgeReleased = false; ignoring = raw; // held at boot? wait for release } void update(uint32_t now) { edgePressed = edgeReleased = false; bool raw = down(); if (raw != lastRead) { lastRead = raw; lastChangeMs = now; } if (raw != stable && (now - lastChangeMs) >= DEBOUNCE_MS) { stable = raw; edgeMs = lastChangeMs; // when the contact actually moved if (stable) { if (!ignoring) edgePressed = true; } else if (ignoring) { ignoring = false; // released - live again } else { edgeReleased = true; } } } bool pressed() const { return edgePressed; } bool released() const { return edgeReleased; } bool held() const { return stable && !ignoring; } // Timestamp of the edge itself, not of the moment debounce confirmed it. // Timing the games off this keeps DEBOUNCE_MS out of the measurement. uint32_t edgeTimeMs() const { return edgeMs; } // Ignore the current press (or one in flight) until the button comes up. void swallow() { if (lastRead || stable) ignoring = true; } private: bool down() const { return digitalRead(pin) == LOW; } uint8_t pin = 0; bool lastRead = false; bool stable = false; bool edgePressed = false; bool edgeReleased = false; bool ignoring = false; uint32_t lastChangeMs = 0; uint32_t edgeMs = 0; }; Button btnA, btnB; static void swallowAll() { btnA.swallow(); btnB.swallow(); } static bool anyPressed() { return btnA.pressed() || btnB.pressed(); } // ------------------------------------------------------------ state machine enum AppState : uint8_t { ST_MENU, ST_TARGET, ST_REACTION }; enum TargetState : uint8_t { TT_READY, TT_RUNNING, TT_RESULT }; enum ReactionState : uint8_t { RX_ARM, RX_WAIT, RX_FALSE, RX_GO, RX_RESULT }; static AppState appState = ST_MENU; static TargetState ttState = TT_READY; static ReactionState rxState = RX_ARM; static uint8_t menuIndex = 0; // 0 = Target Time, 1 = Reaction static bool needsRedraw = true; // set on every state change static uint32_t lastFrameMs = 0; // Mode 1 working values static uint32_t ttStartMs = 0; static uint32_t ttElapsedMs = 0; static int32_t ttDeltaMs = 0; static uint8_t ttDotPhase = 0xFF; // forces a repaint when blanking hits // Mode 2 working values static uint32_t rxPhaseMs = 0; // when the current phase began static uint32_t rxWaitMs = 0; // this round's random arm delay static uint32_t rxTimeMs = 0; // measured reaction time static uint32_t rxBestMs = 0; // 0 = no best yet static bool rxNewBest = false; static bool rxGoLatched = false; // t0 taken once the cue is on screen static const char *MODE_NAMES[2] = { "TARGET TIME", "REACTION TIMER" }; // -------------------------------------------------------------- draw helpers static uint16_t textWidth(const char *s, uint8_t size) { int16_t x1, y1; uint16_t w, h; display.setTextSize(size); display.getTextBounds(s, 0, 0, &x1, &y1, &w, &h); return w; } // Largest size in [minSize, maxSize] whose rendering fits maxW. static uint8_t fitSize(const char *s, uint8_t maxSize, uint8_t minSize, int16_t maxW = SCREEN_W) { for (uint8_t s2 = maxSize; s2 > minSize; s2--) { if (textWidth(s, s2) <= (uint16_t)maxW) return s2; } return minSize; } static void printCentered(const char *s, int16_t y, uint8_t size, uint16_t color = SSD1306_WHITE) { uint16_t w = textWidth(s, size); // also applies setTextSize display.setTextColor(color); display.setCursor((SCREEN_W - (int16_t)w) / 2, y); display.print(s); display.setTextColor(SSD1306_WHITE); } // Centered text scaled down automatically when it would overflow the screen. static void printCenteredFit(const char *s, int16_t y, uint8_t maxSize, uint8_t minSize) { printCentered(s, y, fitSize(s, maxSize, minSize)); } // Full-width inverse-video bar with black text - used for highlights. static void printBar(const char *s, int16_t y, int16_t h) { display.fillRect(0, y, SCREEN_W, h, SSD1306_WHITE); printCentered(s, y + (h - 8) / 2, 1, SSD1306_BLACK); } static void drawTitle(const char *s) { printCentered(s, 0, 1); display.drawFastHLine(0, 10, SCREEN_W, SSD1306_WHITE); } static void drawHint(const char *s) { printCentered(s, SCREEN_H - 8, 1); } // "10.23 s" / "1234 ms" formatting ----------------------------------------- static void fmtSeconds(char *buf, size_t n, uint32_t ms) { snprintf(buf, n, "%lu.%02lu s", (unsigned long)(ms / 1000), (unsigned long)((ms % 1000) / 10)); } static void fmtMillis(char *buf, size_t n, uint32_t ms) { snprintf(buf, n, "%lu ms", (unsigned long)ms); } // --------------------------------------------------------------------- menu static void drawMenu() { display.clearDisplay(); drawTitle("GAME CONSOLE"); for (uint8_t i = 0; i < 2; i++) { int16_t y = 16 + i * 16; if (i == menuIndex) { printBar(MODE_NAMES[i], y, 13); } else { printCentered(MODE_NAMES[i], y + 2, 1); } } drawHint("A:SELECT B:START"); display.display(); } static void updateMenu() { if (btnA.pressed()) { menuIndex ^= 1; needsRedraw = true; } if (btnB.pressed()) { swallowAll(); if (menuIndex == 0) { appState = ST_TARGET; ttState = TT_READY; } else { appState = ST_REACTION; rxState = RX_ARM; rxNewBest = false; } needsRedraw = true; } } static void returnToMenu() { swallowAll(); appState = ST_MENU; needsRedraw = true; } // ------------------------------------------------------- mode 1: target time static const char *ratingFor(uint32_t absErr) { if (absErr <= TIER_PERFECT) return "PERFECT"; if (absErr <= TIER_GREAT) return "GREAT"; if (absErr <= TIER_OK) return "OK"; return "MISS"; } static void drawTarget(uint32_t now) { char buf[24]; display.clearDisplay(); drawTitle("TARGET TIME"); switch (ttState) { case TT_READY: { fmtSeconds(buf, sizeof buf, TARGET_MS); printCenteredFit(buf, 20, 3, 1); printCentered("STOP AT THE TARGET", 46, 1); drawHint("A:START"); break; } case TT_RUNNING: { uint32_t elapsed = now - ttStartMs; if (elapsed < BLANK_AFTER_MS) { fmtSeconds(buf, sizeof buf, elapsed); printCenteredFit(buf, 20, 3, 1); } else { // Clock is hidden - the game is internal timing from here on. // A slow 3-dot cycle confirms it is still running. uint8_t dots = (ttDotPhase % 3) + 1; memset(buf, '.', dots); buf[dots] = '\0'; printCentered(buf, 24, 3); } drawHint("B:STOP"); break; } case TT_RESULT: { fmtSeconds(buf, sizeof buf, ttElapsedMs); printCenteredFit(buf, 14, 2, 1); uint32_t absErr = (uint32_t)(ttDeltaMs < 0 ? -ttDeltaMs : ttDeltaMs); snprintf(buf, sizeof buf, "%c%lu ms %s", ttDeltaMs < 0 ? '-' : '+', (unsigned long)absErr, ttDeltaMs < 0 ? "EARLY" : (ttDeltaMs > 0 ? "LATE" : "EXACT")); printCentered(buf, 32, 1); printBar(ratingFor(absErr), 42, 12); drawHint("ANY:MENU"); break; } } display.display(); } static void updateTarget(uint32_t now) { switch (ttState) { case TT_READY: if (btnA.pressed()) { ttStartMs = btnA.edgeTimeMs(); ttDotPhase = 0xFF; ttState = TT_RUNNING; needsRedraw = true; } break; case TT_RUNNING: { if (btnB.pressed()) { ttElapsedMs = btnB.edgeTimeMs() - ttStartMs; ttDeltaMs = (int32_t)ttElapsedMs - (int32_t)TARGET_MS; ttState = TT_RESULT; swallowAll(); // don't let this press skip the result needsRedraw = true; break; } // Once the clock is blanked we repaint twice a second instead of at // 30 Hz: each display() blocks I2C for ~25 ms, and the stop press // lands in this window - fewer repaints means tighter polling. uint32_t elapsed = now - ttStartMs; if (elapsed >= BLANK_AFTER_MS) { uint8_t phase = ((elapsed - BLANK_AFTER_MS) / 500) % 3; if (phase != ttDotPhase) { ttDotPhase = phase; needsRedraw = true; } } break; } case TT_RESULT: if (anyPressed()) returnToMenu(); break; } } // --------------------------------------------------- mode 2: reaction timer static void drawReaction(uint32_t now) { char buf[24]; if (rxState == RX_GO) { // Inverted full screen - the brightness change is the cue. display.clearDisplay(); display.fillRect(0, 0, SCREEN_W, SCREEN_H, SSD1306_WHITE); printCentered("PRESS B!", 24, 2, SSD1306_BLACK); display.display(); // Pushing the 1 KiB frame buffer over I2C takes ~25 ms; start the clock // when the cue is actually visible, not when we decided to show it. if (!rxGoLatched) { rxPhaseMs = millis(); rxGoLatched = true; } return; } display.clearDisplay(); drawTitle("REACTION TIMER"); switch (rxState) { case RX_ARM: printCentered("HOLD A", 22, 2); printCentered("TO ARM", 42, 1); break; case RX_WAIT: printCentered("WAIT", 22, 2); printCentered("KEEP HOLDING A", 42, 1); break; case RX_FALSE: printCentered("FALSE", 18, 2); printCentered("START", 36, 2); break; case RX_RESULT: { fmtMillis(buf, sizeof buf, rxTimeMs); printCenteredFit(buf, 16, 3, 1); if (rxNewBest) { printCentered("NEW BEST!", 44, 1); } else if (rxBestMs) { snprintf(buf, sizeof buf, "BEST %lu ms", (unsigned long)rxBestMs); printCentered(buf, 44, 1); } drawHint("ANY:MENU"); break; } case RX_GO: break; // handled above } if (rxState != RX_RESULT) { if (rxBestMs) { snprintf(buf, sizeof buf, "BEST %lu ms", (unsigned long)rxBestMs); drawHint(buf); } else { drawHint("B:PRESS ON CUE"); } } display.display(); } static void recordBest(uint32_t rt) { if (rxBestMs == 0 || rt < rxBestMs) { rxBestMs = rt; rxNewBest = true; #if PERSIST_BEST prefs.putUInt("rxbest", rxBestMs); #endif } else { rxNewBest = false; } } static void updateReaction(uint32_t now) { switch (rxState) { case RX_ARM: if (btnA.held()) { rxWaitMs = (uint32_t)random(ARM_MIN_MS, ARM_MAX_MS + 1); rxPhaseMs = now; rxState = RX_WAIT; needsRedraw = true; } break; case RX_WAIT: // Letting go of A - or jumping the gun on B - is a false start. if (!btnA.held() || btnB.pressed()) { rxPhaseMs = now; rxState = RX_FALSE; needsRedraw = true; } else if (now - rxPhaseMs >= rxWaitMs) { rxGoLatched = false; // drawReaction() stamps t0 rxState = RX_GO; needsRedraw = true; } break; case RX_FALSE: if (now - rxPhaseMs >= FALSE_START_MS) { swallowAll(); // require a fresh hold of A rxState = RX_ARM; needsRedraw = true; } break; case RX_GO: { if (!rxGoLatched || !btnB.pressed()) break; // A press whose edge predates the cue was already on its way down // before the screen lit - debounce just hadn't confirmed it yet. int32_t rt = (int32_t)(btnB.edgeTimeMs() - rxPhaseMs); if (rt < 0) { rxPhaseMs = now; rxState = RX_FALSE; needsRedraw = true; break; } rxTimeMs = (uint32_t)rt; recordBest(rxTimeMs); rxState = RX_RESULT; swallowAll(); needsRedraw = true; break; } case RX_RESULT: if (anyPressed()) returnToMenu(); break; } } // -------------------------------------------------------------------- setup static void drawSplash() { display.clearDisplay(); printCentered("TWO-BUTTON", 10, 1); printCentered("GAME", 22, 2); printCentered("CONSOLE", 42, 2); display.display(); } void setup() { Serial.begin(115200); // USB CDC; never waited on btnA.begin(PIN_BTN_A); btnB.begin(PIN_BTN_B); Wire.begin(PIN_SDA, PIN_SCL); Wire.setClock(400000); // fast I2C keeps redraws cheap bool ok = false; for (uint8_t i = 0; i < 3 && !ok; i++) { ok = display.begin(SSD1306_SWITCHCAPVCC, OLED_ADDR); if (!ok) delay(200); } if (!ok) Serial.println(F("SSD1306 not found at 0x3C - check wiring")); display.setTextWrap(false); display.setTextColor(SSD1306_WHITE); randomSeed(esp_random()); // non-repeating arm delays #if PERSIST_BEST prefs.begin("tbgc", false); rxBestMs = prefs.getUInt("rxbest", 0); #endif drawSplash(); delay(900); // splash only - no waits after this needsRedraw = true; } // --------------------------------------------------------------------- loop void loop() { uint32_t now = millis(); btnA.update(now); btnB.update(now); switch (appState) { case ST_MENU: updateMenu(); break; case ST_TARGET: updateTarget(now); break; case ST_REACTION: updateReaction(now); break; } // Only the visible count-up animates. Every other screen is static and // repaints on demand, so the poll loop stays tight where timing matters. bool live = (appState == ST_TARGET && ttState == TT_RUNNING && (now - ttStartMs) < BLANK_AFTER_MS); bool frameDue = live && (now - lastFrameMs >= FRAME_MS); if (needsRedraw || frameDue) { switch (appState) { case ST_MENU: drawMenu(); break; case ST_TARGET: drawTarget(now); break; case ST_REACTION: drawReaction(now); break; } needsRedraw = false; lastFrameMs = now; } }