Built-in project · Arduino Uno R3

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.

The circuit runs here once the page has loaded.

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.

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 pinConnects to
Rotary encoder, KY-040 pin SWArduino 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
OLED display, 128x64 pin VCCArduino Uno R3 pin 5V
Rotary encoder, KY-040 pin GNDArduino Uno R3 pin GND
OLED display, 128x64 pin GNDArduino Uno R3 pin GND
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
// 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 editor

drawKeys 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 editor

Troubleshooting

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.