Rhythm game on an Arduino Uno
Notes fall down four lanes of a WS2812 panel to Ode to Joy. Hit the lit arcade buttons on the beat for perfects and combos; the best score stays in EEPROM. The whole build, an Arduino Uno and 8 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 35 minutes to build and run. Runs in your browser. Free, and no account needed.
Build it as the Rhythm Game Kit: the parts in numbered bags and a booklet that checks every step. Retails for about $51, free on Mokxi.
What you'll learn
- Driving a WS2812 panel with Adafruit_NeoPixel
- Timing by comparing
millis()against due times - Scoring with timing windows and a combo multiplier
- Saving a high score in EEPROM with a marker byte
- Reading buttons with
INPUT_PULLUPand edge detection
- Difficulty: Advanced
- Time: About 35 minutes
- Board: Arduino Uno R3
- Parts: 10
How it works
Notes fall down four colored lanes on a 16 by 8 WS2812 panel. The song is Ode to Joy, and each pitch has its own lane. A note appears seven rows up, 840 ms early, and reaches the bottom row exactly when the piezo plays it.
Press any of the four arcade buttons to start. When a note reaches the bottom, press the button for its lane. Within 60 ms of the beat is a perfect, worth 100 points. Within 140 ms is a good, worth 50. Every ten hits in a row adds another 1x to the multiplier.
A note that slips past breaks your combo. The LCD shows score, combo and the last verdict. Your best score is saved in EEPROM, so it survives a reset.
Parts list
10 parts, plus the jumper wires. They retail for about $47; on Mokxi they're free.
- 1 × Arduino Uno R3In Starter Kit, Claw Machine Kit, Vending Machine Kit.
- 1 × Half breadboard
- 1 × Addressable RGB LEDsIn Claw Machine Kit, Halloween Prop Kit, Christmas Lights Kit.
- 1 × Character LCD with I2C backpackIn Claw Machine Kit, Vending Machine Kit, Rhythm Game Kit.
- 1 × Piezo speakerIn Starter Kit, Claw Machine Kit, Vending Machine Kit.
- 1 × Resistor, 330 ΩIn Starter Kit, Claw Machine Kit, Halloween Prop Kit.
- 4 × LED arcade buttonIn Fight Stick Kit, Rhythm Game Kit, Spaceship Panel 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 12 |
| LED arcade button (4) pin L+ | Arduino Uno R3 pin 10 |
| LED arcade button (3) pin L+ | Arduino Uno R3 pin 9 |
| LED arcade button (2) pin L+ | Arduino Uno R3 pin 8 |
| LED arcade button (1) pin L+ | Arduino Uno R3 pin 7 |
| Resistor, 330 Ω pin 1 | Arduino Uno R3 pin 6 |
| LED arcade button (4) pin 1 | Arduino Uno R3 pin 5 |
| LED arcade button (3) pin 1 | Arduino Uno R3 pin 4 |
| LED arcade button (2) pin 1 | Arduino Uno R3 pin 3 |
| LED arcade button (1) pin 1 | Arduino Uno R3 pin 2 |
| Character LCD with I2C backpack pin VCC | Arduino Uno R3 pin IOREF |
| Addressable RGB LEDs pin VDD | Arduino Uno R3 pin 5V |
| Addressable RGB LEDs pin VSS | Arduino Uno R3 pin GND |
| Piezo speaker pin 2 | Arduino Uno R3 pin GND |
| LED arcade button (1) pin 2 | Arduino Uno R3 pin GND |
| LED arcade button (1) pin L- | Arduino Uno R3 pin GND |
| LED arcade button (2) pin 2 | Arduino Uno R3 pin GND |
| LED arcade button (2) pin L- | Arduino Uno R3 pin GND |
| LED arcade button (3) pin 2 | Arduino Uno R3 pin GND |
| LED arcade button (3) pin L- | Arduino Uno R3 pin GND |
| LED arcade button (4) pin 2 | Arduino Uno R3 pin GND |
| LED arcade button (4) pin L- | Arduino Uno R3 pin GND |
| Character LCD with I2C backpack pin GND | Arduino Uno R3 pin GND |
| Character LCD with I2C backpack pin SDA | Arduino Uno R3 pin A4 |
| Character LCD with I2C backpack pin SCL | Arduino Uno R3 pin A5 |
| Resistor, 330 Ω pin 2 | Addressable RGB LEDs pin DIN |
The code
The firmware exactly as the editor opens it. Change a line there and press Run: it compiles in the browser.
// Rhythm game: notes fall down four lanes of a WS2812 panel, and you hit
// the matching lit arcade button as each one reaches the bottom.
//
// pin 6 the panel: 16 x 8 WS2812s, through 330 ohm on the data line
// pins 2-5 the four arcade buttons, other side to GND (internal pull-ups)
// pins 7-10 the four button lamps
// pin 12 the piezo, other leg to GND
// A4, A5 the 16x2 LCD's I2C backpack (SDA, SCL)
//
// Press any button to start. The song is Ode to Joy, and every melody note
// falls in the lane of its pitch. Hit it within PERFECT_MS of the beat for a
// perfect, within GOOD_MS for a good; let it pass and the combo breaks. The
// best score is kept in EEPROM, so it survives a reset.
//
// # Why the notes fall early
//
// A note has to be on screen before it is due, or nobody could react to it.
// Each row of the panel is ROW_MS of the future, so a note appears at the top
// seven rows (840 ms) ahead of its beat and reaches the bottom row exactly
// when the piezo plays it.
#include <Wire.h>
#include <EEPROM.h>
#include <LiquidCrystal_I2C.h>
#include <Adafruit_NeoPixel.h>
const int PANEL_PIN = 6;
const int PIEZO = 12;
const int BUTTONS[4] = {2, 3, 4, 5};
const int LAMPS[4] = {7, 8, 9, 10};
const int COLS = 16;
const int ROWS = 8;
const int ROW_MS = 120;
const int PERFECT_MS = 60;
const int GOOD_MS = 140;
const int BEAT_MS = 500; // 120 beats a minute
const int LEAD_IN_MS = 2000; // from the start press to the first beat
// Ode to Joy, two phrases. Pitches are an index into HZ, lengths are in
// eighth notes. Each pitch has its lane: C and G on the left, then D, E, F.
const uint16_t HZ[] = {262, 294, 330, 349, 392};
const uint8_t LANE_OF[] = {0, 1, 2, 3, 0};
const uint8_t PITCH[] = {2, 2, 3, 4, 4, 3, 2, 1, 0, 0, 1, 2, 2, 1, 1,
2, 2, 3, 4, 4, 3, 2, 1, 0, 0, 1, 2, 1, 0, 0};
const uint8_t EIGHTHS[] = {2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3, 1, 4,
2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3, 1, 4};
const int NOTES = sizeof(PITCH);
const uint8_t LANE_RGB[4][3] = {{255, 0, 0}, {255, 180, 0}, {0, 255, 0}, {0, 80, 255}};
Adafruit_NeoPixel panel(COLS * ROWS, PANEL_PIN, NEO_GRB + NEO_KHZ800);
LiquidCrystal_I2C lcd(0x27, 16, 2);
unsigned long dueAt[NOTES]; // when each note is due, from the start of the song
uint8_t judged[NOTES]; // 0 waiting, 1 hit, 2 missed
bool playing = false;
unsigned long startAt = 0;
int nextTone = 0;
long score = 0;
int combo = 0;
long best = 0;
bool wasDown[4];
unsigned long flashUntil[4];
unsigned long frameAt = 0;
bool lcdDirty = false;
const char *verdict = "";
void showLine(int row, const char *label, long value) {
lcd.setCursor(0, row);
lcd.print(label);
lcd.print(value);
lcd.print(" ");
}
long loadBest() {
// An erased EEPROM reads 0xFF, so a marker byte says a score was saved.
if (EEPROM.read(0) != 0x42) return 0;
long saved;
EEPROM.get(1, saved);
return saved;
}
void startSong() {
unsigned long t = LEAD_IN_MS;
for (int i = 0; i < NOTES; i++) {
dueAt[i] = t;
judged[i] = 0;
t += (unsigned long)EIGHTHS[i] * BEAT_MS / 2;
}
score = 0;
combo = 0;
nextTone = 0;
verdict = "";
playing = true;
startAt = millis();
lcd.clear();
showLine(0, "Score ", 0);
Serial.println("go");
}
void endSong() {
playing = false;
noTone(PIEZO);
lcd.clear();
showLine(0, "Final ", score);
if (score > best) {
best = score;
EEPROM.update(0, 0x42);
EEPROM.put(1, best);
showLine(1, "New best! ", best);
Serial.println("new best");
} else {
showLine(1, "Best ", best);
}
Serial.print("game over, score ");
Serial.print(score);
Serial.print(", best ");
Serial.println(best);
}
// A press in `lane` at song time `t`: find the nearest waiting note there.
void hit(int lane, long t) {
int found = -1;
long nearest = GOOD_MS + 1;
for (int i = 0; i < NOTES; i++) {
if (judged[i] || LANE_OF[PITCH[i]] != lane) continue;
long off = t - (long)dueAt[i];
if (off < 0) off = -off;
if (off < nearest) {
nearest = off;
found = i;
}
}
if (found < 0) return; // nothing close: a free press, no penalty
judged[found] = 1;
combo++;
int points = nearest <= PERFECT_MS ? 100 : 50;
verdict = nearest <= PERFECT_MS ? "PERFECT" : "GOOD";
// The combo multiplies: every ten in a row adds another 1x.
score += (long)points * (1 + combo / 10);
flashUntil[lane] = millis() + 120;
lcdDirty = true;
Serial.print(verdict);
Serial.print(" ");
Serial.print(t - (long)dueAt[found]);
Serial.print(" ms x");
Serial.println(combo);
}
void draw(long t) {
panel.clear();
for (int lane = 0; lane < 4; lane++) {
const uint8_t *c = LANE_RGB[lane];
bool flash = millis() < flashUntil[lane];
// The hit row along the bottom: dim, or white for a moment after a hit.
for (int x = 0; x < 3; x++) {
uint32_t base = flash ? panel.Color(255, 255, 255) : panel.Color(c[0] / 8, c[1] / 8, c[2] / 8);
panel.setPixelColor((ROWS - 1) * COLS + lane * 4 + x, base);
}
}
for (int i = 0; i < NOTES; i++) {
if (judged[i]) continue;
long ahead = (long)dueAt[i] - t;
if (ahead < -ROW_MS / 2 || ahead > (ROWS - 1) * ROW_MS) continue;
int row = ROWS - 1 - (int)((ahead + ROW_MS / 2) / ROW_MS);
if (row < 0) row = 0;
int lane = LANE_OF[PITCH[i]];
const uint8_t *c = LANE_RGB[lane];
for (int x = 0; x < 3; x++) panel.setPixelColor(row * COLS + lane * 4 + x, c[0], c[1], c[2]);
}
panel.show();
}
void setup() {
for (int i = 0; i < 4; i++) {
pinMode(BUTTONS[i], INPUT_PULLUP);
pinMode(LAMPS[i], OUTPUT);
}
Serial.begin(115200);
panel.begin();
panel.setBrightness(120);
panel.show();
lcd.init();
lcd.backlight();
best = loadBest();
lcd.setCursor(0, 0);
lcd.print("Rhythm game");
showLine(1, "Best ", best);
Serial.println("Rhythm game ready. Press any button.");
}
void loop() {
unsigned long now = millis();
long t = (long)(now - startAt);
for (int lane = 0; lane < 4; lane++) {
bool down = digitalRead(BUTTONS[lane]) == LOW;
if (down && !wasDown[lane]) {
if (playing) {
hit(lane, t);
} else {
startSong();
t = 0;
}
}
wasDown[lane] = down;
// Each button lights while it is held, and flashes on a hit.
digitalWrite(LAMPS[lane], down || now < flashUntil[lane] || (!playing && (now / 400) % 4 == lane));
}
if (!playing) return;
// The song: each note sounds at its beat whether or not it is hit.
if (nextTone < NOTES && t >= (long)dueAt[nextTone]) {
tone(PIEZO, HZ[PITCH[nextTone]], EIGHTHS[nextTone] * BEAT_MS / 2 - 30);
nextTone++;
}
// A note that slips past the window is a miss, and the combo breaks.
for (int i = 0; i < NOTES; i++) {
if (!judged[i] && t > (long)dueAt[i] + GOOD_MS) {
judged[i] = 2;
Serial.println("miss");
combo = 0;
verdict = "MISS";
lcdDirty = true;
}
}
if (lcdDirty) {
lcdDirty = false;
showLine(0, "Score ", score);
lcd.setCursor(0, 1);
lcd.print("x");
lcd.print(combo);
lcd.print(" ");
lcd.print(verdict);
lcd.print(" ");
}
if (now - frameAt >= ROW_MS / 2) {
frameAt = now;
draw(t);
}
if (t > (long)dueAt[NOTES - 1] + 1500) endSong();
}
Code walkthrough
The key functions of the sketch, and what each one does.
startSong sketch.ino
Builds the schedule. Starting at LEAD_IN_MS, it gives every note a due time from its length in eighth notes. It clears the score and combo, records the start time, and shows the score on the LCD.
void startSong() {
unsigned long t = LEAD_IN_MS;
for (int i = 0; i < NOTES; i++) {
dueAt[i] = t;
judged[i] = 0;
t += (unsigned long)EIGHTHS[i] * BEAT_MS / 2;
}
score = 0;
combo = 0;
nextTone = 0;
verdict = "";
playing = true;
startAt = millis();
lcd.clear();
showLine(0, "Score ", 0);
Serial.println("go");
}hit sketch.ino
Called on a button press with the song time. It looks for the nearest unjudged note in that lane within GOOD_MS. If it finds one, it scores perfect or good and raises the combo. A press with no note nearby costs nothing.
// A press in `lane` at song time `t`: find the nearest waiting note there.
void hit(int lane, long t) {
int found = -1;
long nearest = GOOD_MS + 1;
for (int i = 0; i < NOTES; i++) {
if (judged[i] || LANE_OF[PITCH[i]] != lane) continue;
long off = t - (long)dueAt[i];
if (off < 0) off = -off;
if (off < nearest) {
nearest = off;
found = i;
}
}
if (found < 0) return; // nothing close: a free press, no penalty
judged[found] = 1;
combo++;
int points = nearest <= PERFECT_MS ? 100 : 50;
verdict = nearest <= PERFECT_MS ? "PERFECT" : "GOOD";
// The combo multiplies: every ten in a row adds another 1x.
score += (long)points * (1 + combo / 10);
flashUntil[lane] = millis() + 120;
lcdDirty = true;
Serial.print(verdict);
Serial.print(" ");
Serial.print(t - (long)dueAt[found]);
Serial.print(" ms x");
Serial.println(combo);
}draw sketch.ino
Clears the panel, draws a dim hit row along the bottom, and places each unjudged note by how far ahead its due time is. Every ROW_MS of time moves a note down one row.
void draw(long t) {
panel.clear();
for (int lane = 0; lane < 4; lane++) {
const uint8_t *c = LANE_RGB[lane];
bool flash = millis() < flashUntil[lane];
// The hit row along the bottom: dim, or white for a moment after a hit.
for (int x = 0; x < 3; x++) {
uint32_t base = flash ? panel.Color(255, 255, 255) : panel.Color(c[0] / 8, c[1] / 8, c[2] / 8);
panel.setPixelColor((ROWS - 1) * COLS + lane * 4 + x, base);
}
}
for (int i = 0; i < NOTES; i++) {
if (judged[i]) continue;
long ahead = (long)dueAt[i] - t;
if (ahead < -ROW_MS / 2 || ahead > (ROWS - 1) * ROW_MS) continue;
int row = ROWS - 1 - (int)((ahead + ROW_MS / 2) / ROW_MS);
if (row < 0) row = 0;
int lane = LANE_OF[PITCH[i]];
const uint8_t *c = LANE_RGB[lane];
for (int x = 0; x < 3; x++) panel.setPixelColor(row * COLS + lane * 4 + x, c[0], c[1], c[2]);
}
panel.show();
}loop sketch.ino
Checks the four buttons, starting a song or calling hit. While playing, it sounds each note at its due time, marks late notes as misses, updates the LCD, and redraws the panel every 60 ms.
void loop() {
unsigned long now = millis();
long t = (long)(now - startAt);
for (int lane = 0; lane < 4; lane++) {
bool down = digitalRead(BUTTONS[lane]) == LOW;
if (down && !wasDown[lane]) {
if (playing) {
hit(lane, t);
} else {
startSong();
t = 0;
}
}
wasDown[lane] = down;
// Each button lights while it is held, and flashes on a hit.
digitalWrite(LAMPS[lane], down || now < flashUntil[lane] || (!playing && (now / 400) % 4 == lane));
}
if (!playing) return;
// The song: each note sounds at its beat whether or not it is hit.
if (nextTone < NOTES && t >= (long)dueAt[nextTone]) {
tone(PIEZO, HZ[PITCH[nextTone]], EIGHTHS[nextTone] * BEAT_MS / 2 - 30);
nextTone++;
}
// A note that slips past the window is a miss, and the combo breaks.
// ... 28 more lines in the editorTroubleshooting
The usual mistakes with this circuit, and the fix for each.
- A button press does nothing or starts a song by itself. This project
Wire each button between its pin (2 to 5) and ground. The sketch reads a low pin as pressed. - Notes fall in the wrong lane or the lights stay dark. This project
The panel's data line goes through the 330 ohm resistor to pin 6, and its power and ground must be connected. - Most hits count as good instead of perfect. This project
Press as the note reaches the bottom row. Perfect needs you withinPERFECT_MS, 60 ms of the beat. - The first LED flickers, shows wrong colors, or the whole strip glitches. Addressable RGB LEDs
Join the grounds of the strip and the controller. Add a 300 to 500 ohm resistor in series with the data line. Put a 1000 uF capacitor across the strip's 5 V and GND. - The strip works at low brightness but dims, turns yellow, or resets when many LEDs go white. Addressable RGB LEDs
Each LED can draw about 60 mA at full white. Use a 5 V supply sized for the count, feed power in at several points, and lower the brightness in code. - The screen stays blank or shows only a row of blocks. Character LCD with I2C backpack
The I2C address is usually 0x27 or 0x3F. Run an I2C scanner to find yours. Then turn the contrast trimmer on the backpack slowly until text appears. - The backlight is on but there is no text, or text is garbled. Character LCD with I2C backpack
Wire SDA and SCL to the right pins (A4 and A5 on an Uno), power it from 5 V, and call lcd.init() and the backlight call in setup. - A passive piezo stays silent when you just set the pin HIGH. Piezo speaker
A passive piezo needs a changing signal. Use tone(pin, frequency) or a PWM wave. Around 2 to 4 kHz is loudest. A buzzer with a built in oscillator makes sound from plain DC instead.
Change it and keep it
Open it in the editor, change the circuit or the code, and keep your version in a free account.