Built-in project · Arduino Uno R3

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.

The circuit runs here once the page has loaded.

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.

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 pinConnects to
Piezo speaker pin 1Arduino Uno R3 pin 8
Pushbutton pin 1aArduino Uno R3 pin 4
Rotary encoder, KY-040 pin DTArduino Uno R3 pin 3
Rotary encoder, KY-040 pin CLKArduino Uno R3 pin 2
Rotary encoder, KY-040 pin VCCArduino Uno R3 pin 5V
Thumb stick pin VCCArduino Uno R3 pin 5V
OLED display, 128x64 pin VCCArduino Uno R3 pin 5V
Rotary encoder, KY-040 pin GNDArduino Uno R3 pin GND
Rotary encoder, KY-040 pin SWArduino Uno R3 pin GND
Thumb stick pin GNDArduino Uno R3 pin GND
OLED display, 128x64 pin GNDArduino Uno R3 pin GND
Pushbutton pin 2aArduino Uno R3 pin GND
Piezo speaker pin 2Arduino Uno R3 pin GND
Thumb stick pin VRXArduino Uno R3 pin A0
Thumb stick pin VRYArduino Uno R3 pin A1
OLED display, 128x64 pin SDAArduino Uno R3 pin A4
OLED display, 128x64 pin SCLArduino 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.

sketch.ino · Arduino Uno R3
// 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 editor

Troubleshooting

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.