🎮 Arduino • LCD 16×2 • Game

Gioco con Arduino e Display LCD 16×2: salta gli ostacoli

Realizziamo un piccolo runner game direttamente su un display LCD 16×2: il personaggio corre, gli ostacoli scorrono sullo schermo e il giocatore deve saltarli al momento giusto.

DifficoltàIntermedia
Tempo40–50 minuti
Cosa impariSprite, interrupt e LCD
In poche parole: il display viene trasformato in un piccolo schermo da gioco usando caratteri personalizzati. Gli ostacoli scorrono da destra verso sinistra e il pulsante su D2 permette al personaggio di saltare.

Come funziona il gioco

All’avvio compare la scritta Press Start. Premendo il pulsante inizia la partita: il personaggio corre sulla riga inferiore del display e deve evitare i blocchi generati casualmente.

Ogni ostacolo superato aumenta progressivamente il punteggio visualizzato nell’angolo superiore destro. In caso di collisione la partita termina e può essere riavviata con il pulsante.

Video dimostrativo

Materiale necessario

🔌 Arduino Uno
🖥️ Display LCD 16×2
Ω 1 resistenza da 220 Ω
🔘 2 pulsanti / microinterruttori
🔗 Cavetti di collegamento
🧩 Breadboard

🧰 Kit Arduino consigliato

Un kit completo è utile per avere breadboard, cavetti, resistenze, display e molti componenti di base per sperimentare con Arduino.

👉 Guarda il kit Arduino consigliato

Link di affiliazione Amazon: in qualità di Affiliato Amazon ricevo un guadagno dagli acquisti idonei, senza alcun costo aggiuntivo per te.

📘 Vuoi imparare Arduino passo dopo passo?

Se vuoi approfondire e realizzare progetti più completi, puoi dare un’occhiata anche ai miei libri dedicati ad Arduino. All’interno troverai spiegazioni semplici, schemi chiari e molti progetti pratici pensati per chi parte da zero.

👉 Scopri i miei libri su Arduino

Link di affiliazione Amazon: in qualità di Affiliato Amazon ricevo un guadagno dagli acquisti idonei, senza alcun costo aggiuntivo per te.

Schema del circuito

Schema gioco Arduino con display LCD 16x2

Schema del gioco con Arduino Uno e LCD 16×2. L’immagine mantiene una larghezza massima di 641 px.

Pin utilizzati

FunzionePin Arduino
Pulsante START / saltoD2
AutoplayD1
LCD RSD11
LCD ED9
LCD D4D6
LCD D5D5
LCD D6D4
LCD D7D3
Nota sul pin D1: D1 è anche il TX della porta seriale di Arduino Uno. In questo circuito viene usato dalla funzione AUTOPLAY; evita quindi di affidarti contemporaneamente alla comunicazione Serial durante il gioco.

Perché il display può mostrare un personaggio

Il controller dei comuni LCD HD44780 consente di creare alcuni caratteri personalizzati. Lo sketch usa questa possibilità per disegnare il personaggio durante la corsa, il salto e i diversi tipi di blocco.

Ogni sprite è definito come una piccola matrice di 5 × 8 pixel e viene caricato con lcd.createChar().

Codice Arduino commentato

Runner game per LCD 16×2
/*
  GIOCO RUNNER CON ARDUINO UNO E DISPLAY LCD 16x2

  Funzionamento:
  - il personaggio corre sul display;
  - con il pulsante START / SALTO evita gli ostacoli;
  - il punteggio aumenta con la distanza percorsa;
  - gli ostacoli vengono generati in modo casuale;
  - la modalità AUTOPLAY può generare automaticamente il salto.

  Collegamenti LCD:
  RS -> D11
  E  -> D9
  D4 -> D6
  D5 -> D5
  D6 -> D4
  D7 -> D3

  Pulsante START / SALTO -> D2
  Autoplay -> D1
  R/W LCD -> D10
  Contrasto -> D12

  NOTA:
  D1 è anche il pin TX della seriale di Arduino Uno.
  Se utilizzi la funzione AUTOPLAY come in questo circuito,
  evita di usare contemporaneamente la comunicazione Serial.
*/

#include <LiquidCrystal.h>

#define PIN_BUTTON 2
#define PIN_AUTOPLAY 1
#define PIN_READWRITE 10
#define PIN_CONTRAST 12

// Caratteri personalizzati usati per il personaggio
#define SPRITE_RUN1 1
#define SPRITE_RUN2 2
#define SPRITE_JUMP 3
#define SPRITE_JUMP_UPPER '.'
#define SPRITE_JUMP_LOWER 4

// Caratteri personalizzati usati per il terreno
#define SPRITE_TERRAIN_EMPTY ' '
#define SPRITE_TERRAIN_SOLID 5
#define SPRITE_TERRAIN_SOLID_RIGHT 6
#define SPRITE_TERRAIN_SOLID_LEFT 7

// Posizione orizzontale fissa del personaggio
#define HERO_HORIZONTAL_POSITION 1

#define TERRAIN_WIDTH 16

#define TERRAIN_EMPTY 0
#define TERRAIN_LOWER_BLOCK 1
#define TERRAIN_UPPER_BLOCK 2

// Stati possibili del personaggio
#define HERO_POSITION_OFF 0
#define HERO_POSITION_RUN_LOWER_1 1
#define HERO_POSITION_RUN_LOWER_2 2

#define HERO_POSITION_JUMP_1 3
#define HERO_POSITION_JUMP_2 4
#define HERO_POSITION_JUMP_3 5
#define HERO_POSITION_JUMP_4 6
#define HERO_POSITION_JUMP_5 7
#define HERO_POSITION_JUMP_6 8
#define HERO_POSITION_JUMP_7 9
#define HERO_POSITION_JUMP_8 10

#define HERO_POSITION_RUN_UPPER_1 11
#define HERO_POSITION_RUN_UPPER_2 12

// Display LCD 16x2 in modalità parallela
LiquidCrystal lcd(11, 9, 6, 5, 4, 3);

// Buffer che rappresentano le due righe del terreno
static char terrainUpper[TERRAIN_WIDTH + 1];
static char terrainLower[TERRAIN_WIDTH + 1];

// Flag modificato dall'interrupt del pulsante
static volatile bool buttonPushed = false;

// ------------------------------------------------------
// Crea i caratteri personalizzati per personaggio e terreno
// ------------------------------------------------------
void initializeGraphics() {

  static byte graphics[] = {

    // Corsa: posizione 1
    B01100,
    B01100,
    B00000,
    B01110,
    B11100,
    B01100,
    B11010,
    B10011,

    // Corsa: posizione 2
    B01100,
    B01100,
    B00000,
    B01100,
    B01100,
    B01100,
    B01100,
    B01110,

    // Salto
    B01100,
    B01100,
    B00000,
    B11110,
    B01101,
    B11111,
    B10000,
    B00000,

    // Parte inferiore del personaggio durante il salto
    B11110,
    B01101,
    B11111,
    B10000,
    B00000,
    B00000,
    B00000,
    B00000,

    // Blocco pieno
    B11111,
    B11111,
    B11111,
    B11111,
    B11111,
    B11111,
    B11111,
    B11111,

    // Mezzo blocco destro
    B00011,
    B00011,
    B00011,
    B00011,
    B00011,
    B00011,
    B00011,
    B00011,

    // Mezzo blocco sinistro
    B11000,
    B11000,
    B11000,
    B11000,
    B11000,
    B11000,
    B11000,
    B11000
  };

  // Carica 7 caratteri personalizzati nella CGRAM del display
  for (int i = 0; i < 7; ++i) {
    lcd.createChar(i + 1, &graphics[i * 8]);
  }

  // All'avvio il terreno è vuoto
  for (int i = 0; i < TERRAIN_WIDTH; ++i) {
    terrainUpper[i] = SPRITE_TERRAIN_EMPTY;
    terrainLower[i] = SPRITE_TERRAIN_EMPTY;
  }
}

// ------------------------------------------------------
// Fa scorrere il terreno verso sinistra
// ------------------------------------------------------
void advanceTerrain(char* terrain, byte newTerrain) {

  for (int i = 0; i < TERRAIN_WIDTH; ++i) {

    char current = terrain[i];

    char next =
      (i == TERRAIN_WIDTH - 1)
      ? newTerrain
      : terrain[i + 1];

    switch (current) {

      case SPRITE_TERRAIN_EMPTY:

        terrain[i] =
          (next == SPRITE_TERRAIN_SOLID)
          ? SPRITE_TERRAIN_SOLID_RIGHT
          : SPRITE_TERRAIN_EMPTY;

        break;

      case SPRITE_TERRAIN_SOLID:

        terrain[i] =
          (next == SPRITE_TERRAIN_EMPTY)
          ? SPRITE_TERRAIN_SOLID_LEFT
          : SPRITE_TERRAIN_SOLID;

        break;

      case SPRITE_TERRAIN_SOLID_RIGHT:
        terrain[i] = SPRITE_TERRAIN_SOLID;
        break;

      case SPRITE_TERRAIN_SOLID_LEFT:
        terrain[i] = SPRITE_TERRAIN_EMPTY;
        break;
    }
  }
}

// ------------------------------------------------------
// Disegna personaggio, terreno e punteggio.
// Restituisce true se il personaggio collide con un ostacolo.
// ------------------------------------------------------
bool drawHero(
  byte position,
  char* terrainUpper,
  char* terrainLower,
  unsigned int score
) {

  bool collide = false;

  char upperSave =
    terrainUpper[HERO_HORIZONTAL_POSITION];

  char lowerSave =
    terrainLower[HERO_HORIZONTAL_POSITION];

  byte upper;
  byte lower;

  switch (position) {

    case HERO_POSITION_OFF:
      upper = lower = SPRITE_TERRAIN_EMPTY;
      break;

    case HERO_POSITION_RUN_LOWER_1:
      upper = SPRITE_TERRAIN_EMPTY;
      lower = SPRITE_RUN1;
      break;

    case HERO_POSITION_RUN_LOWER_2:
      upper = SPRITE_TERRAIN_EMPTY;
      lower = SPRITE_RUN2;
      break;

    case HERO_POSITION_JUMP_1:
    case HERO_POSITION_JUMP_8:
      upper = SPRITE_TERRAIN_EMPTY;
      lower = SPRITE_JUMP;
      break;

    case HERO_POSITION_JUMP_2:
    case HERO_POSITION_JUMP_7:
      upper = SPRITE_JUMP_UPPER;
      lower = SPRITE_JUMP_LOWER;
      break;

    case HERO_POSITION_JUMP_3:
    case HERO_POSITION_JUMP_4:
    case HERO_POSITION_JUMP_5:
    case HERO_POSITION_JUMP_6:
      upper = SPRITE_JUMP;
      lower = SPRITE_TERRAIN_EMPTY;
      break;

    case HERO_POSITION_RUN_UPPER_1:
      upper = SPRITE_RUN1;
      lower = SPRITE_TERRAIN_EMPTY;
      break;

    case HERO_POSITION_RUN_UPPER_2:
      upper = SPRITE_RUN2;
      lower = SPRITE_TERRAIN_EMPTY;
      break;
  }

  // Inserisce temporaneamente il personaggio nel terreno
  if (upper != ' ') {

    terrainUpper[HERO_HORIZONTAL_POSITION] = upper;

    collide =
      (upperSave == SPRITE_TERRAIN_EMPTY)
      ? false
      : true;
  }

  if (lower != ' ') {

    terrainLower[HERO_HORIZONTAL_POSITION] = lower;

    collide |=
      (lowerSave == SPRITE_TERRAIN_EMPTY)
      ? false
      : true;
  }

  // Numero di cifre da riservare al punteggio
  byte digits =
    (score > 9999) ? 5 :
    (score > 999)  ? 4 :
    (score > 99)   ? 3 :
    (score > 9)    ? 2 : 1;

  // Termina le due stringhe
  terrainUpper[TERRAIN_WIDTH] = '\0';
  terrainLower[TERRAIN_WIDTH] = '\0';

  // Disegna la riga superiore lasciando spazio al punteggio
  char temp = terrainUpper[16 - digits];
  terrainUpper[16 - digits] = '\0';

  lcd.setCursor(0, 0);
  lcd.print(terrainUpper);

  terrainUpper[16 - digits] = temp;

  // Disegna la riga inferiore
  lcd.setCursor(0, 1);
  lcd.print(terrainLower);

  // Mostra il punteggio nell'angolo in alto a destra
  lcd.setCursor(16 - digits, 0);
  lcd.print(score);

  // Ripristina il terreno originale sotto il personaggio
  terrainUpper[HERO_HORIZONTAL_POSITION] = upperSave;
  terrainLower[HERO_HORIZONTAL_POSITION] = lowerSave;

  return collide;
}

// ------------------------------------------------------
// Routine chiamata quando viene premuto il pulsante su D2
// ------------------------------------------------------
void buttonPush() {
  buttonPushed = true;
}

void setup() {

  // R/W del display mantenuto LOW
  pinMode(PIN_READWRITE, OUTPUT);
  digitalWrite(PIN_READWRITE, LOW);

  // Pin previsto dal circuito per il contrasto
  pinMode(PIN_CONTRAST, OUTPUT);
  digitalWrite(PIN_CONTRAST, LOW);

  // Pulsante con pull-up interno
  pinMode(PIN_BUTTON, INPUT);
  digitalWrite(PIN_BUTTON, HIGH);

  // Uscita usata dalla logica AUTOPLAY
  pinMode(PIN_AUTOPLAY, OUTPUT);
  digitalWrite(PIN_AUTOPLAY, HIGH);

  // D2 corrisponde all'interrupt esterno 0 su Arduino Uno
  attachInterrupt(
    digitalPinToInterrupt(PIN_BUTTON),
    buttonPush,
    FALLING
  );

  lcd.begin(16, 2);

  initializeGraphics();
}

void loop() {

  static byte heroPos =
    HERO_POSITION_RUN_LOWER_1;

  static byte newTerrainType =
    TERRAIN_EMPTY;

  static byte newTerrainDuration = 1;

  static bool playing = false;
  static bool blink = false;

  static unsigned int distance = 0;

  // ----------------------------------------------------
  // SCHERMATA INIZIALE / GAME OVER
  // ----------------------------------------------------
  if (!playing) {

    drawHero(
      blink ? HERO_POSITION_OFF : heroPos,
      terrainUpper,
      terrainLower,
      distance >> 3
    );

    if (blink) {
      lcd.setCursor(0, 0);
      lcd.print("Press Start");
    }

    delay(250);

    blink = !blink;

    // Una pressione avvia una nuova partita
    if (buttonPushed) {

      initializeGraphics();

      heroPos =
        HERO_POSITION_RUN_LOWER_1;

      playing = true;
      buttonPushed = false;
      distance = 0;
    }

    return;
  }

  // ----------------------------------------------------
  // SCORRIMENTO DEL TERRENO
  // ----------------------------------------------------
  advanceTerrain(
    terrainLower,
    newTerrainType == TERRAIN_LOWER_BLOCK
      ? SPRITE_TERRAIN_SOLID
      : SPRITE_TERRAIN_EMPTY
  );

  advanceTerrain(
    terrainUpper,
    newTerrainType == TERRAIN_UPPER_BLOCK
      ? SPRITE_TERRAIN_SOLID
      : SPRITE_TERRAIN_EMPTY
  );

  // ----------------------------------------------------
  // GENERAZIONE CASUALE DEGLI OSTACOLI
  // ----------------------------------------------------
  if (--newTerrainDuration == 0) {

    if (newTerrainType == TERRAIN_EMPTY) {

      newTerrainType =
        (random(3) == 0)
        ? TERRAIN_UPPER_BLOCK
        : TERRAIN_LOWER_BLOCK;

      newTerrainDuration =
        2 + random(10);
    }
    else {

      newTerrainType =
        TERRAIN_EMPTY;

      newTerrainDuration =
        10 + random(10);
    }
  }

  // ----------------------------------------------------
  // SALTO MANUALE
  // ----------------------------------------------------
  if (buttonPushed) {

    if (heroPos <= HERO_POSITION_RUN_LOWER_2) {
      heroPos = HERO_POSITION_JUMP_1;
    }

    buttonPushed = false;
  }

  // ----------------------------------------------------
  // DISEGNO E CONTROLLO COLLISIONE
  // ----------------------------------------------------
  if (
    drawHero(
      heroPos,
      terrainUpper,
      terrainLower,
      distance >> 3
    )
  ) {

    // Collisione: partita terminata
    playing = false;
  }
  else {

    // Aggiorna le varie fasi della corsa e del salto
    if (
      heroPos == HERO_POSITION_RUN_LOWER_2 ||
      heroPos == HERO_POSITION_JUMP_8
    ) {

      heroPos =
        HERO_POSITION_RUN_LOWER_1;
    }

    else if (
      heroPos >= HERO_POSITION_JUMP_3 &&
      heroPos <= HERO_POSITION_JUMP_5 &&
      terrainLower[HERO_HORIZONTAL_POSITION] !=
        SPRITE_TERRAIN_EMPTY
    ) {

      heroPos =
        HERO_POSITION_RUN_UPPER_1;
    }

    else if (
      heroPos >= HERO_POSITION_RUN_UPPER_1 &&
      terrainLower[HERO_HORIZONTAL_POSITION] ==
        SPRITE_TERRAIN_EMPTY
    ) {

      heroPos =
        HERO_POSITION_JUMP_5;
    }

    else if (
      heroPos == HERO_POSITION_RUN_UPPER_2
    ) {

      heroPos =
        HERO_POSITION_RUN_UPPER_1;
    }

    else {
      ++heroPos;
    }

    ++distance;

    // Logica AUTOPLAY:
    // genera automaticamente il segnale di salto
    // quando viene rilevato un ostacolo poco più avanti.
    digitalWrite(
      PIN_AUTOPLAY,
      terrainLower[
        HERO_HORIZONTAL_POSITION + 2
      ] == SPRITE_TERRAIN_EMPTY
        ? HIGH
        : LOW
    );
  }

  // Velocità del gioco.
  // Riduci il valore per aumentare la difficoltà.
  delay(200);
}

Come funziona lo sketch

1. Terreno su due righe

Gli array terrainUpper e terrainLower rappresentano le due righe del display. Ad ogni ciclo il terreno viene fatto scorrere verso sinistra.

2. Generazione casuale degli ostacoli

Il programma decide casualmente se inserire un blocco nella parte alta o bassa dello schermo e per quanto tempo deve restare presente.

3. Pulsante tramite interrupt

Il pulsante è collegato a D2, che su Arduino Uno può generare un interrupt esterno. Quando viene premuto, la funzione buttonPush() imposta un flag che avvia il salto.

4. Animazione del salto

Il salto non è una singola posizione: viene suddiviso in diverse fasi, così il personaggio sale, resta per alcuni frame nella riga superiore e poi torna verso il terreno.

5. Collisione

Prima di disegnare il personaggio, il programma controlla se nella stessa posizione è presente un blocco. Se sì, la funzione restituisce una collisione e la partita termina.

6. Punteggio

La variabile distance cresce durante la partita. Una versione ridotta del valore viene visualizzata nell’angolo superiore destro come punteggio.

Funzione AUTOPLAY

La logica AUTOPLAY osserva il terreno poco davanti al personaggio e genera automaticamente il comando di salto quando rileva un ostacolo nella riga inferiore.

Idea interessante: il progetto non usa sensori esterni per l’autoplay. È lo stesso stato interno del gioco a decidere quando deve essere generato il salto.

Modificare la velocità del gioco

La velocità dipende soprattutto dal ritardo alla fine del ciclo:

Velocità del gioco
delay(200);

Riducendo il valore gli ostacoli scorrono più velocemente e il gioco diventa più difficile. Aumentandolo il movimento rallenta.

Simulazione Tinkercad

▶️ Apri la simulazione Tinkercad

Scarica lo sketch

⬇️ Scarica arduino_gioco_lcd_game.ino

Debug rapido

  • se il display non mostra nulla, controlla i collegamenti RS, E e D4–D7;
  • se il pulsante non risponde, verifica D2 e il collegamento verso GND;
  • se i caratteri appaiono strani, controlla che lcd.begin(16, 2) sia corretto;
  • se il gioco è troppo veloce, aumenta il delay(200) finale;
  • se l’AUTOPLAY interferisce con altri circuiti, ricorda che utilizza D1;
  • se modifichi i pin del display, aggiorna anche il costruttore LiquidCrystal.

Varianti del progetto

  • aggiungere più tipi di ostacoli;
  • creare livelli con velocità crescente;
  • aggiungere un buzzer per salto e game over;
  • salvare il record nella EEPROM;
  • mostrare vite o bonus;
  • aggiungere un secondo pulsante per nuove azioni;
  • realizzare una versione su display OLED.

Cosa devi ricordare

  • Il gioco utilizza un LCD 16×2 in modalità parallela.
  • Il pulsante START e salto è collegato a D2.
  • Il personaggio e i blocchi sono caratteri LCD personalizzati.
  • Gli ostacoli vengono generati casualmente.
  • Il punteggio aumenta con la distanza percorsa.
  • La velocità può essere modificata cambiando il ritardo finale.

Domande frequenti

Serve una libreria aggiuntiva?

No. Il progetto utilizza LiquidCrystal, normalmente disponibile con Arduino IDE.

Posso usare un display LCD I2C?

Sì, ma il codice va adattato alla libreria I2C perché cambiano inizializzazione e collegamenti del display.

Come aumento la difficoltà?

Puoi ridurre il ritardo finale, aumentare la frequenza degli ostacoli oppure modificare la loro durata.

Posso salvare il record?

Sì. Puoi memorizzare il punteggio massimo nella EEPROM e confrontarlo con il punteggio ottenuto al termine di ogni partita.