Built-in project · Arduino Uno R3

Dino game on an I2C LCD, on an Arduino Uno

Dino game on an I2C LCD: the browser's offline dinosaur game on a 16x2 screen, with custom characters, one jump button and a faster pace as you score. The whole build, an Arduino Uno and 3 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.

Intermediate Runs in your browser. Free, and no account needed.

The parts retail for about $33. On Mokxi they're free.

Dino game on an I2C LCD · Arduino Uno R3live0.000 s 0.00x
Press Run, watch the demo, then press the button to play
The circuit itself, running here on the simulator. Press what can be pressed; click anything else to open it in the editor.

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
// Dino game on a 16x2 I2C LCD: the offline game from a web browser, on an
// Arduino Uno. A dinosaur runs, cacti come at it, and one button jumps.
//
//   LCD      SDA -> A4, SCL -> A5, VCC -> 5 V, GND -> GND (address 0x27)
//   button   pin 2 and GND (INPUT_PULLUP: a press reads LOW)
//   piezo    pin 8 and GND
//
// # How it draws
//
// The LCD only shows characters, so the dinosaur and the cacti are custom
// characters: a 5x8 pixel picture each, stored in the LCD with createChar().
// The display can hold eight of these. This game uses five: two running
// frames, a jumping frame, and a small and a big cactus.
//
// # How it plays
//
// Every tick the cacti move one column left. The dinosaur stays in column 1.
// A press puts it on the top row for four ticks, long enough to clear one
// cactus. If a cactus reaches column 1 while the dinosaur is on the bottom
// row, the game is over. The ticks get shorter as the score goes up.
//
// Before a game starts the dinosaur plays by itself, jumping every cactus, so
// you can see how it works. Press the button to take over.
//
// # Why the button is on an interrupt
//
// Sending characters to an I2C LCD takes a few milliseconds each. A quick tap
// that came and went while the screen was being written would be missed if
// the sketch only looked at the pin between frames. Pin 2 is one of the Uno's
// two interrupt pins, so attachInterrupt() runs press() the moment the pin
// falls, whatever the loop is doing, and the loop picks the press up after.
//
// The screen is also written cell by cell, only where something changed, so a
// frame costs a handful of characters rather than all 32.

#include <Wire.h>
#include <LiquidCrystal_I2C.h>

const uint8_t BUTTON = 2;
const uint8_t PIEZO = 8;

const uint8_t DINO_COL = 1;
const uint8_t JUMP_TICKS = 4;
// A new cactus never comes closer than this many columns behind the last one,
// so every gap can be jumped.
const uint8_t MIN_GAP = 6;

LiquidCrystal_I2C lcd(0x27, 16, 2);

// The custom characters, one row of five pixels per byte.
byte run1[8] = {0b00111, 0b00101, 0b00111, 0b10110, 0b11111, 0b01110, 0b01010, 0b01000};
byte run2[8] = {0b00111, 0b00101, 0b00111, 0b10110, 0b11111, 0b01110, 0b01010, 0b00010};
byte jumpy[8] = {0b00111, 0b00101, 0b00111, 0b10110, 0b11111, 0b01110, 0b10001, 0b00000};
byte cactus[8] = {0b00000, 0b00000, 0b00100, 0b00101, 0b10101, 0b10111, 0b11100, 0b00100};
byte bigCactus[8] = {0b00100, 0b00101, 0b10101, 0b10101, 0b10111, 0b11100, 0b00100, 0b00100};

const uint8_t RUN1 = 0, RUN2 = 1, JUMP = 2, CACTUS = 3, BIG_CACTUS = 4;

// What is in each column of the ground row: ' ' for nothing, else a character.
char ground[16];
uint8_t sinceLast = MIN_GAP;
uint8_t jumpLeft = 0;
unsigned int score = 0;
unsigned int best = 0;
bool demo = true;
unsigned long ticks = 0;

void clearGround() {
  for (uint8_t i = 0; i < 16; i++) ground[i] = ' ';
  sinceLast = MIN_GAP;
  jumpLeft = 0;
}

// What the LCD shows now, so draw() sends only the cells that change.
char shown[2][16];

volatile bool pressedFlag = false;
volatile unsigned long pressedAt = 0;

// Runs on the falling edge of pin 2. A contact bounces for a few ms, so any
// edge within 50 ms of the last one is the same press.
void press() {
  unsigned long now = millis();
  if (now - pressedAt > 50) pressedFlag = true;
  pressedAt = now;
}

bool takePress() {
  noInterrupts();
  bool was = pressedFlag;
  pressedFlag = false;
  interrupts();
  return was;
}

bool buttonDown() {
  return digitalRead(BUTTON) == LOW;
}

void jump() {
  if (jumpLeft == 0) {
    jumpLeft = JUMP_TICKS;
    if (!demo) tone(PIEZO, 880, 30);
  }
}

// Put one character in one cell, if it is not there already.
void cell(uint8_t col, uint8_t row, char c) {
  if (shown[row][col] == c) return;
  shown[row][col] = c;
  lcd.setCursor(col, row);
  lcd.write(byte(c));
}

void forgetScreen() {
  lcd.clear();
  for (uint8_t r = 0; r < 2; r++) {
    for (uint8_t i = 0; i < 16; i++) shown[r][i] = ' ';
  }
}

void draw() {
  bool up = jumpLeft > 0;
  // the score, right-aligned in the top corner
  char digits[5];
  unsigned int s = score;
  for (int8_t d = 4; d >= 0; d--) {
    digits[d] = (s == 0 && d < 4) ? ' ' : (char)('0' + s % 10);
    s /= 10;
  }
  for (uint8_t i = 0; i < 5; i++) cell(11 + i, 0, digits[i]);
  cell(DINO_COL, 0, up ? JUMP : ' ');
  for (uint8_t i = 0; i < 16; i++) {
    char c = ground[i];
    if (i == DINO_COL && !up) c = ticks % 2 ? RUN2 : RUN1;
    cell(i, 1, c);
  }
}

// One step of the game. Returns false when the dinosaur hits a cactus.
bool step() {
  for (uint8_t i = 0; i < 15; i++) ground[i] = ground[i + 1];
  ground[15] = ' ';
  sinceLast++;
  if (sinceLast > MIN_GAP && random(3) == 0) {
    ground[15] = random(2) ? CACTUS : BIG_CACTUS;
    sinceLast = 0;
  }
  // The demo looks two columns ahead and jumps in time.
  if (demo && ground[DINO_COL + 2] != ' ') jump();
  if (jumpLeft > 0) jumpLeft--;
  bool up = jumpLeft > 0;
  if (!up && ground[DINO_COL] != ' ') return false;
  if (ground[DINO_COL - 1] != ' ' && ground[DINO_COL] == ' ' && !demo) {
    // a cactus just went under the dinosaur: a point
    score++;
  }
  ticks++;
  return true;
}

void startGame() {
  demo = false;
  score = 0;
  clearGround();
  forgetScreen();
  Serial.println("game on: press to jump");
}

void gameOver() {
  tone(PIEZO, 150, 400);
  Serial.print("game over: score ");
  Serial.println(score);
  if (score > best) best = score;
  lcd.clear();
  lcd.setCursor(3, 0);
  lcd.print("GAME OVER");
  lcd.setCursor(0, 1);
  lcd.print("Score ");
  lcd.print(score);
  lcd.setCursor(10, 1);
  lcd.print("Hi ");
  lcd.print(best);
  delay(1500);
  while (buttonDown()) {
  }
  takePress();
  // Back to the demo until the next press.
  demo = true;
  score = 0;
  clearGround();
  forgetScreen();
  Serial.println("demo: press the button to play");
}

void setup() {
  Serial.begin(115200);
  pinMode(BUTTON, INPUT_PULLUP);
  pinMode(PIEZO, OUTPUT);
  lcd.init();
  lcd.backlight();
  lcd.createChar(RUN1, run1);
  lcd.createChar(RUN2, run2);
  lcd.createChar(JUMP, jumpy);
  lcd.createChar(CACTUS, cactus);
  lcd.createChar(BIG_CACTUS, bigCactus);
  // Nothing is wired to A0, so it reads noise: different cacti each game.
  randomSeed(analogRead(A0));
  clearGround();
  forgetScreen();
  attachInterrupt(digitalPinToInterrupt(BUTTON), press, FALLING);
  Serial.println("demo: press the button to play");
}

void loop() {
  // Faster as the score climbs: 220 ms a tick to start, 100 ms at best.
  unsigned long tick = 220;
  if (score > 0) tick = score >= 30 ? 100 : 220 - score * 4;
  unsigned long until = millis() + tick;
  while (millis() < until) {
    if (takePress()) {
      if (demo) {
        startGame();
        return;
      }
      jump();
    }
  }
  if (!step()) {
    if (demo) {
      // The demo never misses; this only happens if a cactus spawned too close.
      clearGround();
    } else {
      gameOver();
      return;
    }
  }
  draw();
  if (!demo && ticks % 20 == 0) {
    Serial.print("score ");
    Serial.println(score);
  }
}

Parts list

5 parts, plus the jumper wires. They retail for about $33; on Mokxi they're free.

How it is wired

8 connections, pin by pin, read from the circuit itself. Each line is one set of pins joined together, by a jumper wire or a breadboard strip.

  • Arduino Uno R3 pin 8; Piezo speaker pin 1
  • Arduino Uno R3 pin 2; Arcade button pin 2
  • Arduino Uno R3 pin A4; Character LCD with I2C backpack pin SDA
  • Arduino Uno R3 pin A5; Character LCD with I2C backpack pin SCL
  • Ground: Character LCD with I2C backpack pin GND
  • 5 V: Character LCD with I2C backpack pin VCC
  • Ground: Arcade button pin 1
  • Ground: Piezo speaker pin 2

Change it and keep it

Open it in the editor, change the circuit or the code, and keep your version in a free account.