Community project

WiFi Flapping Bird Game

Arduino
Photo of WiFi Flapping Bird Game
Generated with AI

silas

Last updated August 27, 2026

Build a flapping bird-style game on an Arduino with a colorful 3.3-inch touchscreen display. The player taps a button to make a character jump over oncoming obstacles while navigating a scrolling road scene with dynamic scoring.

This guide provides a complete wiring diagram showing how to safely connect the ILI9341 TFT display to an Arduino Uno using a logic-level converter and 3.3V regulator, a full parts list, ready-to-upload firmware with multiple character skins and game states, and step-by-step assembly instructions to get the game running in minutes.

Wiring diagram

Wiring diagram for WiFi Flapping Bird Game

Gather all the parts

QtyComponent
1

ILI9341 TFT Touchscreen

240 × 320 SPI

240x320 SPI TFT display using the ILI9341 LCD controller with an XPT2046 resistive touch controller sharing the SPI bus

1

AMS1117 3.3V Regulator

3.3 V

Linear LDO regulator, 5 V (or higher) in -> 3.3 V out, 1 A max. Used to drop a 5 V supply down to 3.3 V for ESP32 / Pico when no on-board regulator is available.

1

74LVC245 5 V to 3.3 V logic-level converter module

5-channel used

A small converter board that makes the Nano's 5 volt picture signals safe for the 3.3 volt screen.

1

USB-C 5V Adapter

5 V USB

USB-C wall adapter delivering regulated 5 V to the board's USB or VBUS rail. Default wired power source for desktop / stationary projects.

1

Push Button

Momentary push button

Momentary push button switch

Assemble it in 4 steps

1. Place the Nano and screen

Put the Arduino Nano, ILI9341 screen, 3.3 V regulator module, and 74LVC245 logic converter on the breadboard. Keep the screen unpowered until every wire is checked.

  • The screen uses 3.3 V, not the Nano's 5 V pin directly. The regulator makes the safe screen power.
  • Do not connect the screen VCC pin to Nano 5 V — too much voltage can permanently damage the screen.

2. Make the safe screen power and signal connections

Connect Nano 5V to regulator VIN (power), Nano GND to regulator GND (ground), and regulator VOUT to the 3V3 rail (safe screen power). Connect screen VCC and TFT_BL to 3V3 (power), and screen GND to GND (ground). Connect converter VCC to 3V3 (power) and converter GND to GND (ground). Connect Nano D11 to converter A1_MOSI (data), D13 to A2_SCK (clock), D10 to A3_CS (screen select), D9 to A4_DC (screen command/data), and D8 to A5_RST (screen reset). Then connect converter B1_MOSI to screen MOSI (data), B2_SCK to screen SCK (clock), B3_CS to screen TFT_CS (screen select), B4_DC to screen TFT_DC (screen command/data), and B5_RST to screen TFT_RST (screen reset). Connect screen MISO to Nano D12 (unused return signal). Connect screen TOUCH_CS to 3V3 (power) so its unused touch controller stays disabled.

  • The converter labels may differ by maker. Use a unidirectional 74LVC245-style 5 V-to-3.3 V converter, with its 5 V input side facing the Nano and its 3.3 V output side facing the screen.
  • The screen's MISO wire is only for the unused touch controller and is safe directly to Nano D12 because it is a 3.3 V output.
  • Make sure every module shares the same GND rail — without a shared ground, the screen cannot understand the Nano's signals.
  • Do not use a BSS138 I²C-style converter for the fast screen clock; it can make the display unreliable.

3. Wire the jump button

Put the momentary push button across the centre gap of the breadboard so its two sides are separate. Connect one side of the button to Nano D2 (signal), and connect the opposite side to Nano GND (ground).

  • No extra resistor is needed: the Nano turns on a small built-in resistor that keeps D2 steady until you press the button.
  • On the menu, a quick press changes the runner skin and holding the button for about one second starts the game. During a run, a quick press jumps over a car.
  • Do not connect the button to Nano 5 V — this button must only connect D2 to GND when pressed.

4. Power and play

Look once more for swapped VCC and GND wires, then plug the Nano into USB. Press the button to choose a runner skin, hold the button for one second to start, and press it each time a car approaches to jump over it.

  • If the screen stays blank, first check its 3V3 and GND wires, then check the five converter channels in the same order as the screen wiring step.
  • Disconnect USB before moving wires. A loose wire can touch a neighbour and damage the screen or Nano.

Review all connections

1. Connections between "regulator_3v3" and "Arduino"

Functionregulator_3v3Arduino
powerVIN5V
groundGNDGND
powerVOUT3V3

2. Connections between "ili9341_display" and "Arduino"

Functionili9341_displayArduino
powerVCC3V3
groundGNDGND
spiMISOGPIO 12
spiMOSI74LVC245 5 V to 3.3 V logic-level converter module B1_MOSIEXT
spiSCK74LVC245 5 V to 3.3 V logic-level converter module B2_SCKEXT
digitalTFT_CS74LVC245 5 V to 3.3 V logic-level converter module B3_CSEXT
digitalTFT_DC74LVC245 5 V to 3.3 V logic-level converter module B4_DCEXT
digitalTFT_RST74LVC245 5 V to 3.3 V logic-level converter module B5_RSTEXT
powerTOUCH_CS3V3
powerTFT_BL3V3

3. Connections between "level_converter" and "Arduino"

Functionlevel_converterArduino
powerVCC3V3
groundGNDGND
digitalA1_MOSIGPIO 11
digitalA2_SCKGPIO 13
digitalA3_CSGPIO 10
digitalA4_DCGPIO 9
digitalA5_RSTGPIO 8

4. Connections between "usb_power_adapter" and "Arduino"

Functionusb_power_adapterArduino
power+5VArduino Nano USB connectorEXT
groundGNDArduino Nano USB connector groundEXT

5. Connections between "flap_button" and "Arduino"

Functionflap_buttonArduino
groundGNDGND
digitalSIGNALGPIO 2

Deploy the firmware

#include <Arduino.h>
#include <SPI.h>
#include <Adafruit_GFX.h>
#include <Adafruit_ILI9341.h>
#include <avr/pgmspace.h>


enum GameState { MENU, RUNNING, CRASHING, OVER };

struct Runner { int16_t y; float vy; uint8_t skin; bool grounded; } player;


// Forward declarations
const char* skinName(uint8_t skin);
uint16_t skinColor(uint8_t skin);
void newCar();
void resetRound();
void drawCloud(int16_t x, int16_t y);
void drawScene();
void drawCar();
void drawRunner();
void drawHud();
void drawMenu();
void drawOver();
bool hitsCar();
void startCrash();
void jump();
void handleTouch();

const uint8_t TOUCH_PIN = 2;
const uint8_t TFT_CS_PIN = 10;
const uint8_t TFT_DC_PIN = 9;
const uint8_t TFT_RST_PIN = 8;
const int16_t W = 320, H = 240, ROAD_Y = 184, PLAYER_X = 55;
const uint16_t SKY = 0x8E7D, GRASS = 0x5E64, ROAD = 0x4208, DIRT = 0xB4A0;

Adafruit_ILI9341 tft(TFT_CS_PIN, TFT_DC_PIN, TFT_RST_PIN);

GameState state = MENU;


int16_t carX, carW, carH;
uint16_t carColor;
uint16_t score = 0, wins = 0, losses = 0, roadOffset = 0;
unsigned long lastFrame = 0, touchDownAt = 0, crashAt = 0;
bool wasTouched = false, longUsed = false;

// 20 x 20 animal body.  Each byte is one row of pixels; this stays in PROGMEM flash.
const uint8_t PROGMEM ANIMAL_BODY[20] = {
  0x00,0x00,0x18,0x3C,0x7E,0xFF,0xFF,0xFF,0xFF,0xFF,
  0x7F,0x3F,0x3F,0x7F,0xFF,0xE7,0xC3,0xC3,0x00,0x00
};
// A second flash image gives the legs a convincing alternate running position.
const uint8_t PROGMEM LEGS_A[8] = {0x00,0x00,0x24,0x24,0x24,0x66,0x42,0x00};
const uint8_t PROGMEM LEGS_B[8] = {0x00,0x00,0x42,0x66,0x24,0x24,0x24,0x00};
// Long neck/head overlay used only by Ostrich and Chicken, also held in flash.
const uint8_t PROGMEM NECK_HEAD[20] = {
  0x00,0x00,0x00,0x00,0x00,0x18,0x18,0x18,0x18,0x1C,
  0x3E,0x7F,0x3E,0x18,0x18,0x18,0x00,0x00,0x00,0x00
};
const char* skinName(uint8_t skin) {
  const char* const names[] = {"EAGLE", "BIRD", "CROW", "OSTRICH", "CHICKEN"};
  return names[skin];
}
uint16_t skinColor(uint8_t skin) {
  const uint16_t colors[] = {0x8A22, ILI9341_YELLOW, 0x4208, 0x6B40, 0xFFFF};
  return colors[skin];
}
void newCar() {
  carX = W + random(15, 80);
  carW = random(36, 62);
  carH = random(23, 36);
  const uint16_t colors[] = {ILI9341_RED, 0x001F, 0xFBE0, 0x780F, 0x07E0};
  carColor = colors[random(0, 5)];
}
void resetRound() {
  player.y = ROAD_Y - 20; player.vy = 0; player.grounded = true;
  score = 0; roadOffset = 0; newCar();
}
void drawCloud(int16_t x, int16_t y) {
  tft.fillCircle(x, y, 7, ILI9341_WHITE); tft.fillCircle(x + 10, y - 4, 10, ILI9341_WHITE);
  tft.fillCircle(x + 21, y, 7, ILI9341_WHITE); tft.fillRect(x - 4, y, 30, 7, ILI9341_WHITE);
}
void drawScene() {
  tft.fillScreen(SKY);
  drawCloud(45 - (roadOffset / 5) % 130, 35); drawCloud(220 - (roadOffset / 7) % 160, 72);
  tft.fillRect(0, 143, W, 41, GRASS);
  tft.fillRect(0, ROAD_Y, W, 42, ROAD);
  for (int16_t x = -(roadOffset % 46); x < W; x += 46) tft.fillRect(x, ROAD_Y + 20, 26, 4, ILI9341_YELLOW);
  tft.fillRect(0, 226, W, 14, DIRT);
}
void drawCar() {
  int16_t y = ROAD_Y - carH;
  tft.fillRoundRect(carX, y + 7, carW, carH - 7, 5, carColor);
  tft.fillRoundRect(carX + 7, y, carW - 18, 16, 4, carColor);
  tft.fillRect(carX + 11, y + 3, (carW - 24) / 2, 7, 0xBDF7);
  tft.fillRect(carX + carW / 2 + 2, y + 3, (carW - 24) / 2, 7, 0xBDF7);
  tft.fillCircle(carX + 10, ROAD_Y - 2, 7, ILI9341_BLACK); tft.fillCircle(carX + carW - 10, ROAD_Y - 2, 7, ILI9341_BLACK);
  tft.fillCircle(carX + 10, ROAD_Y - 2, 3, 0xC618); tft.fillCircle(carX + carW - 10, ROAD_Y - 2, 3, 0xC618);
}
void drawRunner() {
  uint16_t body = skinColor(player.skin);
  int16_t baseY = player.y;
  for (uint8_t row = 0; row < 20; row++) {
    uint8_t bits = pgm_read_byte(&ANIMAL_BODY[row]);
    for (uint8_t col = 0; col < 8; col++) if (bits & (0x80 >> col)) tft.drawPixel(PLAYER_X + col, baseY + row, body);
  }
  // Eye, beak, and a shaded wing make the small PROGMEM sprite readable on the TFT.
  tft.fillCircle(PLAYER_X + 5, baseY + 5, 2, ILI9341_WHITE); tft.drawPixel(PLAYER_X + 6, baseY + 5, ILI9341_BLACK);
  tft.fillTriangle(PLAYER_X + 8, baseY + 7, PLAYER_X + 14, baseY + 9, PLAYER_X + 8, baseY + 11, 0xFD20);
  tft.drawLine(PLAYER_X + 1, baseY + 11, PLAYER_X + 6, baseY + 15, 0x7BEF);
  if (player.skin >= 3) {
    for (uint8_t row = 0; row < 20; row++) {
      uint8_t bits = pgm_read_byte(&NECK_HEAD[row]);
      for (uint8_t col = 0; col < 8; col++) if (bits & (0x80 >> col)) tft.drawPixel(PLAYER_X - 5 + col, baseY - 14 + row, body);
    }
  }
  const uint8_t* legs = ((millis() / 90) & 1) ? LEGS_A : LEGS_B;
  for (uint8_t row = 0; row < 8; row++) {
    uint8_t bits = pgm_read_byte(&legs[row]);
    for (uint8_t col = 0; col < 8; col++) if (bits & (0x80 >> col)) tft.drawPixel(PLAYER_X + col, baseY + 14 + row, 0x4A21);
  }
}
void drawHud() {
  tft.setTextColor(ILI9341_WHITE, SKY); tft.setTextSize(2); tft.setCursor(8, 8); tft.print(score);
  tft.setTextSize(1); tft.setCursor(227, 8); tft.print("W:"); tft.print(wins); tft.print(" L:"); tft.print(losses);
}
void drawMenu() {
  drawScene();
  tft.setTextColor(ILI9341_WHITE); tft.setTextSize(3); tft.setCursor(37, 24); tft.print("ROAD RUNNER");
  tft.fillRoundRect(40, 92, 240, 74, 8, ILI9341_BLACK); tft.drawRoundRect(40, 92, 240, 74, 8, ILI9341_WHITE);
  tft.setTextSize(1); tft.setCursor(72, 105); tft.print("SELECT YOUR RUNNER");
  tft.setTextSize(2); tft.setCursor(96, 123); tft.print(skinName(player.skin));
  tft.setTextSize(1); tft.setCursor(53, 177); tft.print("Tap: change skin  Hold: play");
}
void drawOver() {
  drawScene(); drawCar(); drawRunner();
  tft.fillRoundRect(42, 72, 236, 68, 8, ILI9341_BLACK); tft.drawRoundRect(42, 72, 236, 68, 8, ILI9341_WHITE);
  tft.setTextColor(ILI9341_WHITE); tft.setTextSize(3); tft.setCursor(73, 83); tft.print("CAR HIT!");
  tft.setTextSize(1); tft.setCursor(60, 119); tft.print("Tap: replay  Hold: menu");
}
bool hitsCar() {
  int16_t px1 = PLAYER_X, px2 = PLAYER_X + 13;
  int16_t py1 = player.y, py2 = player.y + 21;
  return px2 > carX + 3 && px1 < carX + carW - 3 && py2 > ROAD_Y - carH + 5 && py1 < ROAD_Y;
}
void startCrash() { state = CRASHING; crashAt = millis(); losses++; player.vy = -3.4; }
void jump() { if (player.grounded) { player.vy = -8.2; player.grounded = false; } }
void handleTouch() {
  // The button is wired from D2 to GND; INPUT_PULLUP makes a press read LOW.
  bool touched = digitalRead(TOUCH_PIN) == LOW;
  unsigned long now = millis();
  if (touched && !wasTouched) { touchDownAt = now; longUsed = false; }
  if (touched && !longUsed && now - touchDownAt > 700) {
    longUsed = true;
    if (state == MENU) { resetRound(); state = RUNNING; }
    else if (state == OVER) { state = MENU; drawMenu(); }
  }
  if (!touched && wasTouched && !longUsed) {
    if (state == MENU) { player.skin = (player.skin + 1) % 5; drawMenu(); }
    else if (state == OVER) { resetRound(); state = RUNNING; }
    else if (state == RUNNING) jump();
  }
  wasTouched = touched;
}
void setup() {
  pinMode(TOUCH_PIN, INPUT_PULLUP); randomSeed(analogRead(A0)); player.skin = 0;
  tft.begin(); tft.setRotation(1); drawMenu();
}
void loop() {
  handleTouch();
  if (state != RUNNING && state != CRASHING) return;
  unsigned long now = millis(); if (now - lastFrame < 35) return; lastFrame = now;
  if (state == CRASHING) {
    player.vy += 0.72; player.y += (int16_t)player.vy; roadOffset += 6;
    drawScene(); drawCar(); drawRunner();
    if (((now / 70) & 1) == 0) { tft.drawLine(PLAYER_X - 8, player.y - 5, PLAYER_X + 16, player.y + 25, ILI9341_RED); tft.drawLine(PLAYER_X + 16, player.y - 5, PLAYER_X - 8, player.y + 25, ILI9341_YELLOW); }
    if (now - crashAt > 850 || player.y >= ROAD_Y - 20) { state = OVER; drawOver(); }
    return;
  }
  player.vy += 0.55; player.y += (int16_t)player.vy;
  if (player.y >= ROAD_Y - 20) { player.y = ROAD_Y - 20; player.vy = 0; player.grounded = true; }
  carX -= 7; roadOffset += 7;
  if (carX + carW < 0) { score++; if (score % 10 == 0) wins++; newCar(); }
  if (hitsCar()) { startCrash(); return; }
  drawScene(); drawCar(); drawRunner(); drawHud();
}

Remix this project

Make it yours in one click

Open a full copy of this project in your own Schematik workspace — diagram, code, parts, and assembly steps included. Swap the sensor, add features, or redesign the whole thing with AI. The author's original stays untouched.

Open in Schematik