Bop-It reaction game on an Arduino Uno
BOP the button, TWIST the knob or FLICK the stick before the time bar runs out. The limit shrinks a tenth of a second every point, and a wrong move ends the game. The whole build, an Arduino Uno and 5 more parts, runs here in your browser on the firmware below; open it in the editor to change the wiring or the code and run it again.
Teaching? Assign it to your class in one click.
Advanced About 30 minutes to build and run. Runs in your browser. Free, and no account needed.
The parts retail for about $37. On Mokxi they're free.
What you'll learn
- Games
- Controllers
- Displays
- Sound
- Reading a button
- Difficulty: Advanced
- Time: About 30 minutes
- Board: Arduino Uno R3
- Parts: 7
How it works
BOP the button, TWIST the knob or FLICK the stick before the time bar runs out. The limit shrinks a tenth of a second every point, and a wrong move ends the game.
Arduino Uno R3 runs the sketch below. The circuit around it: Rotary encoder, KY-040, Thumb stick, OLED display, 128x64, Pushbutton, Piezo speaker. pinMode sets BUTTON_PIN as an input with its pull-up on. digitalRead reads whether BUTTON_PIN is HIGH or LOW. analogRead measures the voltage on STICK_X as a number. delay waits 250 milliseconds. Serial.begin opens the serial port at 115200 baud.
Parts list
7 parts, plus the jumper wires. They retail for about $37; on Mokxi they're free.
- 1 × Arduino Uno R3In Starter Kit, Claw Machine Kit, Vending Machine Kit.
- 1 × Half breadboard
- 1 × Rotary encoder, KY-040
- 1 × Thumb stickIn Claw Machine Kit, Spaceship Panel Kit.
- 1 × OLED display, 128x64In Fight Stick Kit, Pocket Pet Kit, Slot Machine Kit.
- 1 × PushbuttonIn Starter Kit, Christmas Lights Kit, Logic Lab Kit.
- 1 × Piezo speakerIn Starter Kit, Claw Machine Kit, Vending Machine Kit.
Wiring table
Every connection, pin by pin, read from the circuit itself. Each row joins the pin on the left to the pin on the right, by a jumper wire or a breadboard strip.
| Part and pin | Connects to |
|---|---|
| Piezo speaker pin 1 | Arduino Uno R3 pin 8 |
| Pushbutton pin 1a | Arduino Uno R3 pin 4 |
| Rotary encoder, KY-040 pin DT | Arduino Uno R3 pin 3 |
| Rotary encoder, KY-040 pin CLK | Arduino Uno R3 pin 2 |
| Rotary encoder, KY-040 pin VCC | Arduino Uno R3 pin 5V |
| Thumb stick pin VCC | Arduino Uno R3 pin 5V |
| OLED display, 128x64 pin VCC | Arduino Uno R3 pin 5V |
| Rotary encoder, KY-040 pin GND | Arduino Uno R3 pin GND |
| Rotary encoder, KY-040 pin SW | Arduino Uno R3 pin GND |
| Thumb stick pin GND | Arduino Uno R3 pin GND |
| OLED display, 128x64 pin GND | Arduino Uno R3 pin GND |
| Pushbutton pin 2a | Arduino Uno R3 pin GND |
| Piezo speaker pin 2 | Arduino Uno R3 pin GND |
| Thumb stick pin VRX | Arduino Uno R3 pin A0 |
| Thumb stick pin VRY | Arduino Uno R3 pin A1 |
| OLED display, 128x64 pin SDA | Arduino Uno R3 pin A4 |
| OLED display, 128x64 pin SCL | Arduino Uno R3 pin A5 |
The code
The firmware exactly as the editor opens it. Change a line there and press Run: it compiles in the browser.
// Bop-It style reaction game: a button, a knob and a stick, a time limit that
// shrinks, and a score.
//
// pin 4 pushbutton to GND (BOP it)
// pins 2, 3 rotary encoder CLK and DT (TWIST it)
// A0, A1 joystick VRX and VRY (FLICK it)
// pin 8 piezo
// A4, A5 OLED SDA and SCL (address 0x3C)
//
// # How a round goes
//
// The screen shows a command with a little picture, and the piezo gives a
// different beep for each one. You have a time limit to do exactly that and
// nothing else: BOP presses the button, TWIST turns the knob two clicks, FLICK
// pushes the stick right over to any side. Do it in time and you score a point,
// and the next round's limit is a tenth of a second shorter. Do the wrong thing,
// or nothing, and the game is over. The limit starts at two seconds and bottoms
// out at six tenths, which is about as fast as a thumb can go.
//
// # Reading three kinds of input
//
// Each input needs a different idea, and that is what this sketch is for.
//
// The button is an EDGE. Holding it down must not count again, so the sketch
// remembers whether it was down a moment ago and only reacts when that
// changes. It uses the Uno's INPUT_PULLUP, so pressed reads LOW.
//
// The knob is a COUNT. A rotary encoder sends two out-of-step pulses, and
// which one leads says which way you turned. The Encoder library decodes it
// on the two interrupt pins (2 and 3) and counts four steps per click. The
// sketch zeroes the count when a round starts and looks for two clicks.
//
// The stick is a LEVEL that must be "armed". It rests in the middle (about
// 512 of 1023). A flick is a push past 380 either side of that. But if you
// were still holding it from the last round it would count again, so a flick
// only counts once the stick has been back near the middle since.
//
// Nothing else is allowed during a round. The moment any input other than the
// one asked for happens, that is a wrong move.
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#include <Encoder.h>
const int BUTTON_PIN = 4;
const int BUZZER_PIN = 8;
const int STICK_X = A0;
const int STICK_Y = A1;
enum Command { BOP, TWIST, FLICK, COMMANDS };
const char *const NAMES[COMMANDS] = {"BOP!", "TWIST!", "FLICK!"};
const unsigned int CUES[COMMANDS] = {880, 660, 1100}; // a beep for each
const unsigned long START_LIMIT = 2000; // ms for the first round
const unsigned long STEP = 100; // ms less every point
const unsigned long MIN_LIMIT = 600;
Adafruit_SSD1306 display(128, 64, &Wire, -1);
Encoder knob(2, 3);
enum State { IDLE, PLAYING, OVER };
State state = IDLE;
int score = 0;
int best = 0;
Command command = BOP;
unsigned long limit = START_LIMIT;
unsigned long roundStart = 0;
unsigned long lastBar = 0;
bool buttonWasDown = false;
bool stickArmed = true;
int barWidth = 0;
const char *why = "";
// ---- inputs -------------------------------------------------------------
bool buttonPressed() {
bool down = digitalRead(BUTTON_PIN) == LOW;
bool edge = down && !buttonWasDown;
buttonWasDown = down;
return edge;
}
bool stickFlicked() {
int x = analogRead(STICK_X) - 512;
int y = analogRead(STICK_Y) - 512;
bool pushed = abs(x) > 380 || abs(y) > 380;
bool centred = abs(x) < 200 && abs(y) < 200;
if (centred) stickArmed = true;
if (pushed && stickArmed) {
stickArmed = false;
return true;
}
return false;
}
bool knobTurned() {
return abs(knob.read()) >= 8; // 4 steps a click, so two clicks
}
// ---- drawing ------------------------------------------------------------
void icon(Command c) {
const int x = 22, y = 36; // the centre of a 40 pixel square
if (c == BOP) {
display.fillCircle(x, y, 11, SSD1306_WHITE);
display.drawCircle(x, y, 16, SSD1306_WHITE);
} else if (c == TWIST) {
display.drawCircle(x, y, 12, SSD1306_WHITE);
display.drawLine(x, y, x + 8, y - 8, SSD1306_WHITE);
// an arrowhead at the end of the turn
display.fillTriangle(x - 18, y - 4, x - 12, y - 10, x - 12, y + 2, SSD1306_WHITE);
display.fillTriangle(x + 18, y + 4, x + 12, y + 10, x + 12, y - 2, SSD1306_WHITE);
} else {
display.fillCircle(x, y, 5, SSD1306_WHITE);
display.fillTriangle(x, y - 18, x - 5, y - 11, x + 5, y - 11, SSD1306_WHITE);
display.fillTriangle(x, y + 18, x - 5, y + 11, x + 5, y + 11, SSD1306_WHITE);
display.fillTriangle(x - 18, y, x - 11, y - 5, x - 11, y + 5, SSD1306_WHITE);
display.fillTriangle(x + 18, y, x + 11, y - 5, x + 11, y + 5, SSD1306_WHITE);
}
}
void scoreLine() {
display.setTextSize(1);
display.setTextColor(SSD1306_WHITE);
display.setCursor(0, 0);
display.print("SCORE ");
display.print(score);
display.setCursor(84, 0);
display.print("BEST ");
display.print(best);
}
void drawRound() {
display.clearDisplay();
scoreLine();
icon(command);
display.setTextSize(2);
display.setCursor(52, 28);
display.print(NAMES[command]);
display.drawRect(0, 56, 128, 8, SSD1306_WHITE);
barWidth = -1; // force the bar to be drawn
display.display();
}
// Only the time bar changes during a round, so only it is redrawn. display()
// then sends just the 8-pixel blocks of the screen that changed.
void drawBar(unsigned long left) {
int w = (int)((long)left * 124 / (long)limit);
if (w < 0) w = 0;
if (w == barWidth) return;
if (barWidth < 0) {
display.fillRect(2, 58, w, 4, SSD1306_WHITE); // the first time: draw it all
} else if (w < barWidth) {
display.fillRect(2 + w, 58, barWidth - w, 4, SSD1306_BLACK); // rub out the end
}
barWidth = w;
display.display();
}
void drawIdle() {
display.clearDisplay();
display.setTextColor(SSD1306_WHITE);
display.setTextSize(3);
display.setCursor(10, 6);
display.print("BOP-IT");
display.setTextSize(1);
display.setCursor(14, 38);
display.print("PRESS THE BUTTON");
display.setCursor(0, 54);
display.print("BEST ");
display.print(best);
display.display();
}
void drawOver() {
display.clearDisplay();
display.setTextColor(SSD1306_WHITE);
display.setTextSize(2);
display.setCursor(10, 2);
display.print("GAME OVER");
display.setTextSize(1);
display.setCursor(0, 24);
display.print(why);
display.setCursor(0, 38);
display.print("SCORE ");
display.print(score);
display.setCursor(0, 48);
display.print("BEST ");
display.print(best);
display.setCursor(66, 54);
display.print("PRESS TO PLAY");
display.display();
}
// ---- the game -----------------------------------------------------------
void nextRound() {
command = (Command)random(COMMANDS);
limit = START_LIMIT > MIN_LIMIT + STEP * score ? START_LIMIT - STEP * score : MIN_LIMIT;
tone(BUZZER_PIN, CUES[command], 80);
knob.write(0); // count clicks from now
stickArmed = false; // the stick has to come back to the middle first
buttonWasDown = digitalRead(BUTTON_PIN) == LOW;
drawRound();
roundStart = millis();
Serial.print("round ");
Serial.print(score + 1);
Serial.print(" ");
Serial.print(NAMES[command]);
Serial.print(" limit ");
Serial.println(limit);
}
void gameOver(const char *reason) {
why = reason;
if (score > best) best = score;
state = OVER;
tone(BUZZER_PIN, 150, 400);
Serial.print("game over: ");
Serial.print(reason);
Serial.print(" score ");
Serial.print(score);
Serial.print(" best ");
Serial.println(best);
drawOver();
}
void startGame() {
score = 0;
state = PLAYING;
randomSeed(micros());
Serial.println("go");
delay(250);
nextRound();
}
void setup() {
Serial.begin(115200);
Serial.println("Mokxi Uno: bop-it");
pinMode(BUTTON_PIN, INPUT_PULLUP);
pinMode(BUZZER_PIN, OUTPUT);
display.begin(SSD1306_SWITCHCAPVCC, 0x3C);
drawIdle();
}
void loop() {
if (state != PLAYING) {
if (buttonPressed()) {
delay(20);
startGame();
}
return;
}
unsigned long spent = millis() - roundStart;
bool pressed = buttonPressed();
bool turned = knobTurned();
bool flicked = stickFlicked();
bool right = (command == BOP && pressed) || (command == TWIST && turned) || (command == FLICK && flicked);
bool wrong = (pressed && command != BOP) || (turned && command != TWIST) || (flicked && command != FLICK);
if (right && !wrong) {
score++;
Serial.print("ok ");
Serial.print(score);
Serial.print(" in ");
Serial.println(spent);
tone(BUZZER_PIN, 1500, 40);
nextRound();
} else if (wrong) {
gameOver("WRONG MOVE");
} else if (spent >= limit) {
gameOver("TOO SLOW");
} else if (millis() - lastBar >= 50) {
lastBar = millis();
drawBar(limit - spent);
}
}
Code walkthrough
The key functions of the sketch, and what each one does.
setup sketch.ino
Runs once when the board starts. It gets the pins and libraries ready.
void setup() {
Serial.begin(115200);
Serial.println("Mokxi Uno: bop-it");
pinMode(BUTTON_PIN, INPUT_PULLUP);
pinMode(BUZZER_PIN, OUTPUT);
display.begin(SSD1306_SWITCHCAPVCC, 0x3C);
drawIdle();
}loop sketch.ino
Runs over and over for as long as the board has power. This is where the project does its work.
void loop() {
if (state != PLAYING) {
if (buttonPressed()) {
delay(20);
startGame();
}
return;
}
unsigned long spent = millis() - roundStart;
bool pressed = buttonPressed();
bool turned = knobTurned();
bool flicked = stickFlicked();
bool right = (command == BOP && pressed) || (command == TWIST && turned) || (command == FLICK && flicked);
bool wrong = (pressed && command != BOP) || (turned && command != TWIST) || (flicked && command != FLICK);
if (right && !wrong) {
score++;
Serial.print("ok ");
Serial.print(score);
Serial.print(" in ");
Serial.println(spent);
tone(BUZZER_PIN, 1500, 40);
nextRound();
} else if (wrong) {
gameOver("WRONG MOVE");
} else if (spent >= limit) {
// ... 6 more lines in the editorTroubleshooting
The usual mistakes with this circuit, and the fix for each.
- Turning the knob counts twice, skips, or goes the wrong way. The contacts bounce and polling is too slow. Rotary encoder
Use interrupts or a proper encoder library on pins CLK and DT. Use INPUT_PULLUP, since the contacts are open and shut to ground. Swap CLK and DT to flip direction. - The KY-040 pins read wrong or noisy. The board has pull-ups to its + pin, so + must be powered, and its switch needs debounce. Rotary encoder
Connect + to 5 V or 3.3 V to match your board's logic level and GND to ground. Debounce SW for about 20 ms. One click often equals four state changes. - The axis reads about half scale at rest instead of exactly zero, and the values drift a little, so the cursor or motor creeps. Thumb stick
Center is about half of the ADC range (about 512 on a 10 bit Arduino). Read it at startup and subtract that, then add a dead zone of a few counts. - The push button reads random values or always looks pressed. The switch pin is a plain contact to ground and has no pull-up on the stick. Thumb stick
Set the switch pin to INPUT_PULLUP and treat LOW as pressed. Debounce it in code. Power the stick from the same voltage as the ADC reference. - The OLED stays blank. OLED display
Check SDA and SCL are not swapped, and power it with 3.3 V or 5 V as its board allows. Most use address 0x3C, some 0x3D. Call display.begin() with the matching address. - Text never shows up even though the code runs. OLED display
Drawing only writes to a buffer. Call display.display() after drawing to push it out. Also set a text color with setTextColor(WHITE) and clear the buffer between frames. - The input reads random HIGH and LOW when the button is not pressed. Pushbutton
A floating pin picks up noise. Add a 10 k pull-down or pull-up resistor, or use INPUT_PULLUP and wire the button between the pin and GND. Then pressed reads LOW. - The button seems always pressed, or never changes. Pushbutton
A 4-leg tactile switch has legs joined in pairs. Place it across the breadboard center gap so the pairs sit on different rows. Wired along one side, the legs are already connected.
Change it and keep it
Open it in the editor, change the circuit or the code, and keep your version in a free account.