Word game on an Arduino Uno
Guess the five-letter word in six tries on an OLED, picking letters with a rotary knob. Filled boxes are right where they are, open boxes belong somewhere else. The whole build, an Arduino Uno and 2 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 25 minutes to build and run. Runs in your browser. Free, and no account needed.
The parts retail for about $35. On Mokxi they're free.
What you'll learn
- Reading a rotary encoder with interrupts so no turn is missed
- Short press versus long press on one button
- Storing words in flash with PROGMEM to save RAM
- Scoring a guess: right place, wrong place, or absent
- Drawing text and boxes on an OLED
- Difficulty: Advanced
- Time: About 25 minutes
- Board: Arduino Uno R3
- Parts: 4
How it works
The sketch picks a secret five-letter word from a list stored in flash. You get six tries. Turn the rotary knob to move a cursor over the letters on the OLED, and press to type the highlighted letter. Hold the press for half a second to rub out the last letter.
With five letters in the row, another press checks the guess. A filled box means the right letter in the right place. An open box means the letter is in the word somewhere else. No box means it is not in the word. The on-screen keyboard keeps score the same way.
Any five letters count as a guess. The first press seeds the random pick from your timing. After the game ends, press to play again.
Parts list
4 parts, plus the jumper wires. They retail for about $35; 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 × OLED display, 128x64In Fight Stick Kit, Pocket Pet Kit, Slot Machine Kit.
Kits that can build it
This starter kit has every part in the circuit. Each kit's page lists what else it can build.
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 |
|---|---|
| Rotary encoder, KY-040 pin SW | 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 |
| OLED display, 128x64 pin VCC | Arduino Uno R3 pin 5V |
| Rotary encoder, KY-040 pin GND | Arduino Uno R3 pin GND |
| OLED display, 128x64 pin GND | Arduino Uno R3 pin GND |
| 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.
// Word game: guess the five-letter word in six tries, on a 128x64 OLED.
//
// Wiring:
// SSD1306 OLED SDA -> A4 SCL -> A5 VCC -> 5 V GND -> GND
// KY-040 knob CLK -> 2 DT -> 3 SW -> 4 + -> 5 V GND -> GND
//
// Turn the knob to pick a letter on the keyboard at the right, press to type
// it, and hold the press for half a second to rub out the last letter. With
// five letters in the row, a press checks the guess:
//
// a filled box the right letter in the right place
// an open box the letter is in the word, somewhere else
// no box the letter is not in the word
//
// The keyboard keeps score the same way, and letters that are not in the word
// drop off it. Any five letters count as a guess: an Uno has no room for a
// whole dictionary, only for the answers.
//
// # Fitting it in an Uno
//
// The answers live in flash with PROGMEM, five bytes each, and never touch
// the 2 KB of RAM, which the OLED's 1 KB frame buffer already half fills.
// The knob is read in two interrupt handlers, so a turn is never missed while
// the sketch is busy sending the screen over I2C.
#include <avr/pgmspace.h>
#include "mokxi_i2c.h"
#include "mokxi_ssd1306.h"
const uint8_t CLK_PIN = 2;
const uint8_t DT_PIN = 3;
const uint8_t SW_PIN = 4;
SoftI2c bus(A5, A4, 400);
Ssd1306 oled(bus, SSD1306_ADDRESS);
// Everyday five-letter words, written for this game.
const char WORDS[] PROGMEM =
"APPLE" "BEACH" "BRAIN" "BREAD" "BRICK" "BRUSH" "CANDY" "CHAIR" "CHALK" "CHESS"
"CLOCK" "CLOUD" "CORAL" "CRANE" "CROWN" "DANCE" "DREAM" "DRINK" "EAGLE" "EARTH"
"FAIRY" "FEAST" "FIELD" "FLAME" "FLOOR" "FROST" "FRUIT" "GHOST" "GIANT" "GLASS"
"GRAPE" "GRASS" "HEART" "HONEY" "HORSE" "HOUSE" "JELLY" "JUICE" "KNIFE" "LEMON"
"LIGHT" "MAGIC" "MANGO" "MONEY" "MOUSE" "MUSIC" "NIGHT" "NOVEL" "OCEAN" "OLIVE"
"ONION" "PAINT" "PARTY" "PEACH" "PIANO" "PILOT" "PIZZA" "PLANE" "PLANT" "PLATE"
"QUEEN" "QUIET" "RADIO" "RIVER" "ROBOT" "SALAD" "SCARF" "SHEEP" "SHELL" "SHIRT"
"SKATE" "SMILE" "SNAKE" "SPOON" "STAMP" "STONE" "STORM" "SUGAR" "SWEET" "TABLE"
"TIGER" "TOAST" "TOWEL" "TRAIN" "TRUCK" "UNCLE" "VOICE" "WATCH" "WATER" "WHALE"
"WHEEL" "WORLD" "YACHT" "YOUTH" "ZEBRA" "BADGE" "CABIN" "DAISY" "GLOVE" "LUNCH";
const uint8_t WORD_COUNT = sizeof(WORDS) / 5;
char answer[6];
char guesses[6][6];
// per letter of each guess: 0 not in the word, 1 elsewhere, 2 right place
uint8_t marks[6][5];
uint8_t row = 0; // the guess being typed
uint8_t typed = 0; // letters in it so far
// per letter of the alphabet: 0 untried, 1 not in the word, 2 elsewhere, 3 placed
uint8_t keyState[26];
uint8_t cursor = 0; // the letter the knob is on
bool over = false;
bool won = false;
bool seeded = false;
// The knob: a quarter step on every edge, a detent every four.
volatile int8_t quarters = 0;
volatile int8_t clicks = 0;
volatile uint8_t encState = 3;
const int8_t STEP[16] = {0, -1, 1, 0, 1, 0, 0, -1, -1, 0, 0, 1, 0, 1, -1, 0};
static void knob()
{
uint8_t now = (uint8_t)((digitalRead(CLK_PIN) << 1) | digitalRead(DT_PIN));
if (now == encState) {
return;
}
quarters = (int8_t)(quarters + STEP[(encState << 2) | now]);
encState = now;
if (now == 3) {
if (quarters >= 4) clicks++;
if (quarters <= -4) clicks--;
quarters = 0;
}
}
// ------------------------------------------------------------------ drawing
const int16_t CELL = 10;
const int16_t GRID_X = 2;
const int16_t KEYS_X = 60;
/** One letter cell: filled, boxed, bare, or a blank still to fill. */
static void __attribute__((noinline)) paintCell(int16_t x, int16_t y, char ch, uint8_t look)
{
oled.fillRect(x, y, CELL - 1, CELL - 1, look == 3);
if (look == 2) {
oled.rect(x, y, CELL - 1, CELL - 1, true);
} else if (look == 0) {
oled.hLine(x, (int16_t)(y + CELL - 2), CELL - 1, true);
}
if (ch) {
oled.drawChar((int16_t)(x + 2), (int16_t)(y + 1), ch, 1, look != 3);
}
}
static void drawCell(uint8_t r, uint8_t c)
{
bool checked = r < row || (over && r == row);
// 0 a blank being typed, 1 not in the word, 2 elsewhere, 3 in place
uint8_t look = checked ? (uint8_t)(marks[r][c] + 1) : 0;
paintCell((int16_t)(GRID_X + c * CELL), (int16_t)(r * CELL + 2), guesses[r][c], look);
}
static void drawKeys()
{
oled.fillRect(KEYS_X - 2, 0, 128 - KEYS_X + 2, 40, false);
for (uint8_t i = 0; i < 26; i++) {
int16_t x = (int16_t)(KEYS_X + (i % 9) * 7);
int16_t y = (int16_t)(1 + (i / 9) * 11);
char ch = (char)('A' + i);
bool on = keyState[i] != 1; // letters not in the word drop off
if (i == cursor) {
oled.rect((int16_t)(x - 1), (int16_t)(y - 1), 7, 9, true);
}
if (keyState[i] == 3) {
oled.fillRect(x, y, 5, 7, true);
oled.drawChar(x, y, ch, 1, false);
} else if (on) {
oled.drawChar(x, y, ch, 1, true);
if (keyState[i] == 2) {
oled.hLine(x, (int16_t)(y + 8), 5, true);
}
} else if (i == cursor) {
oled.setPixel((int16_t)(x + 2), (int16_t)(y + 3), true);
}
}
}
static void drawStatus(const char *line1, const char *line2)
{
oled.fillRect(KEYS_X - 2, 40, 128 - KEYS_X + 2, 24, false);
oled.text(KEYS_X, 44, line1);
oled.text(KEYS_X, 54, line2);
}
static void drawAll()
{
oled.clear();
for (uint8_t r = 0; r < 6; r++) {
for (uint8_t c = 0; c < 5; c++) {
drawCell(r, c);
}
}
oled.vLine(KEYS_X - 5, 0, 64, true);
drawKeys();
}
// ----------------------------------------------------------------- the game
static void newGame()
{
uint8_t pick = (uint8_t)random(WORD_COUNT);
for (uint8_t i = 0; i < 5; i++) {
answer[i] = (char)pgm_read_byte(&WORDS[pick * 5 + i]);
}
answer[5] = '\0';
memset(guesses, 0, sizeof guesses);
memset(marks, 0, sizeof marks);
memset(keyState, 0, sizeof keyState);
row = 0;
typed = 0;
over = false;
won = false;
drawAll();
drawStatus("TURN, PRESS", "HOLD: RUB OUT");
oled.display();
Serial.println("New word. Six tries.");
}
static void checkGuess()
{
char *g = guesses[row];
uint8_t left[26] = {0};
for (uint8_t i = 0; i < 5; i++) {
marks[row][i] = 0;
if (g[i] == answer[i]) {
marks[row][i] = 2;
} else {
left[answer[i] - 'A']++;
}
}
// A letter that appears once in the word is marked once, even if you guess it twice.
for (uint8_t i = 0; i < 5; i++) {
if (marks[row][i] == 0 && left[g[i] - 'A'] > 0) {
marks[row][i] = 1;
left[g[i] - 'A']--;
}
}
bool all = true;
Serial.print(g);
Serial.print(" ");
for (uint8_t i = 0; i < 5; i++) {
uint8_t k = (uint8_t)(g[i] - 'A');
uint8_t now = marks[row][i] == 2 ? 3 : (marks[row][i] == 1 ? 2 : 1);
if (now > keyState[k] || keyState[k] == 0) {
keyState[k] = now;
}
all = all && marks[row][i] == 2;
Serial.print(marks[row][i] == 2 ? 'G' : (marks[row][i] == 1 ? 'Y' : '.'));
}
Serial.println();
if (all) {
over = true;
won = true;
} else if (row == 5) {
over = true;
} else {
row++;
typed = 0;
}
for (uint8_t r = 0; r < 6; r++) {
for (uint8_t c = 0; c < 5; c++) {
drawCell(r, c);
}
}
drawKeys();
if (over) {
const char *praise[6] = {"GENIUS!", "AMAZING!", "GREAT!", "NICE!", "PHEW!", "JUST IN TIME"};
drawStatus(won ? praise[row] : answer, "PRESS: AGAIN");
Serial.print(won ? "Got it in " : "The word was ");
if (won) {
Serial.println(row + 1);
} else {
Serial.println(answer);
}
} else {
drawStatus("TURN, PRESS", "HOLD: RUB OUT");
}
}
void setup()
{
Serial.begin(9600);
pinMode(CLK_PIN, INPUT_PULLUP);
pinMode(DT_PIN, INPUT_PULLUP);
pinMode(SW_PIN, INPUT_PULLUP);
oled.begin();
delay(10);
encState = (uint8_t)((digitalRead(CLK_PIN) << 1) | digitalRead(DT_PIN));
attachInterrupt(digitalPinToInterrupt(CLK_PIN), knob, CHANGE);
attachInterrupt(digitalPinToInterrupt(DT_PIN), knob, CHANGE);
Serial.println("Word game: turn to pick a letter, press to type it");
newGame();
}
bool swWas = false;
uint32_t swDownAt = 0;
bool held = false;
void loop()
{
bool dirty = false;
noInterrupts();
int8_t turned = clicks;
clicks = 0;
interrupts();
if (turned != 0) {
cursor = (uint8_t)((cursor + 26 + (turned % 26)) % 26);
drawKeys();
dirty = true;
}
bool sw = digitalRead(SW_PIN) == LOW;
uint32_t now = millis();
if (sw && !swWas) {
swDownAt = now;
held = false;
if (!seeded) {
randomSeed(micros()); // a person's timing: nothing on the board can guess it
seeded = true;
newGame();
}
}
if (sw && !held && !over && now - swDownAt >= 500) {
held = true; // a long press rubs out
if (typed > 0) {
typed--;
guesses[row][typed] = 0;
drawCell(row, typed);
dirty = true;
}
}
if (!sw && swWas && !held) {
if (over) {
newGame();
} else if (typed < 5) {
guesses[row][typed] = (char)('A' + cursor);
drawCell(row, typed);
typed++;
dirty = true;
} else {
checkGuess();
dirty = true;
}
}
swWas = sw;
if (dirty) {
oled.display();
}
delay(5);
}
Code walkthrough
The key functions of the sketch, and what each one does.
knob sketch.ino
Runs on every change of the encoder's CLK or DT pin. It looks up the step for that pair of states and adds it to quarters. When the knob comes to rest, four quarter steps make one click. The loop then reads clicks safely.
static void knob()
{
uint8_t now = (uint8_t)((digitalRead(CLK_PIN) << 1) | digitalRead(DT_PIN));
if (now == encState) {
return;
}
quarters = (int8_t)(quarters + STEP[(encState << 2) | now]);
encState = now;
if (now == 3) {
if (quarters >= 4) clicks++;
if (quarters <= -4) clicks--;
quarters = 0;
}
}checkGuess sketch.ino
Scores a full row in two passes. First it marks letters in the right place and counts the unmatched letters of the answer. Then it marks wrong-place letters, using up that count so a repeated letter is not over-marked. It also updates the keyboard and the end-of-game message.
static void checkGuess()
{
char *g = guesses[row];
uint8_t left[26] = {0};
for (uint8_t i = 0; i < 5; i++) {
marks[row][i] = 0;
if (g[i] == answer[i]) {
marks[row][i] = 2;
} else {
left[answer[i] - 'A']++;
}
}
// A letter that appears once in the word is marked once, even if you guess it twice.
for (uint8_t i = 0; i < 5; i++) {
if (marks[row][i] == 0 && left[g[i] - 'A'] > 0) {
marks[row][i] = 1;
left[g[i] - 'A']--;
}
}
bool all = true;
Serial.print(g);
Serial.print(" ");
for (uint8_t i = 0; i < 5; i++) {
uint8_t k = (uint8_t)(g[i] - 'A');
uint8_t now = marks[row][i] == 2 ? 3 : (marks[row][i] == 1 ? 2 : 1);
if (now > keyState[k] || keyState[k] == 0) {
keyState[k] = now;
}
// ... 33 more lines in the editordrawKeys sketch.ino
Draws the 26 letters in rows of nine on the right side of the screen. The cursor letter gets a box. A letter in the right place is drawn solid, a wrong-place letter gets an underline, and a letter that is not in the word drops off.
static void drawKeys()
{
oled.fillRect(KEYS_X - 2, 0, 128 - KEYS_X + 2, 40, false);
for (uint8_t i = 0; i < 26; i++) {
int16_t x = (int16_t)(KEYS_X + (i % 9) * 7);
int16_t y = (int16_t)(1 + (i / 9) * 11);
char ch = (char)('A' + i);
bool on = keyState[i] != 1; // letters not in the word drop off
if (i == cursor) {
oled.rect((int16_t)(x - 1), (int16_t)(y - 1), 7, 9, true);
}
if (keyState[i] == 3) {
oled.fillRect(x, y, 5, 7, true);
oled.drawChar(x, y, ch, 1, false);
} else if (on) {
oled.drawChar(x, y, ch, 1, true);
if (keyState[i] == 2) {
oled.hLine(x, (int16_t)(y + 8), 5, true);
}
} else if (i == cursor) {
oled.setPixel((int16_t)(x + 2), (int16_t)(y + 3), true);
}
}
}loop sketch.ino
Applies any knob clicks to the cursor, then reads the button. A short press types a letter, checks a full row, or starts a new game when over. A hold of 500 ms removes the last letter. The OLED is only sent when something changed.
void loop()
{
bool dirty = false;
noInterrupts();
int8_t turned = clicks;
clicks = 0;
interrupts();
if (turned != 0) {
cursor = (uint8_t)((cursor + 26 + (turned % 26)) % 26);
drawKeys();
dirty = true;
}
bool sw = digitalRead(SW_PIN) == LOW;
uint32_t now = millis();
if (sw && !swWas) {
swDownAt = now;
held = false;
if (!seeded) {
randomSeed(micros()); // a person's timing: nothing on the board can guess it
seeded = true;
newGame();
}
}
if (sw && !held && !over && now - swDownAt >= 500) {
held = true; // a long press rubs out
if (typed > 0) {
// ... 26 more lines in the editorTroubleshooting
The usual mistakes with this circuit, and the fix for each.
- The cursor moves the wrong way or skips letters This project
Check that CLK goes to pin 2 and DT goes to pin 3. Swapping them reverses the direction. - Nothing shows on the screen This project
The OLED uses SDA on A4 and SCL on A5. Check those and its 5 V and ground. - A long press types a letter instead of rubbing out This project
Keep the button down for a full half second. A shorter press types the letter when you let go. - 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 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. - Upload fails or nothing runs after wiring pins 0 and 1 to a module. Arduino Uno R3
Pins 0 and 1 are the USB serial port. Move the module to other pins, or unplug it while uploading. Also check the right board is selected.
Change it and keep it
Open it in the editor, change the circuit or the code, and keep your version in a free account.