Community project

Stacker Game With LCD Display

tarik.almalki15

Published July 14, 2026

Arduino2 components4 assembly steps
Remix this project

Build a stacker game that displays dice rolls on a 16x2 LCD screen, controlled by a joystick module. This Arduino project combines a character LCD with a dual-axis joystick to create an interactive gaming experience where players can trigger dice rolls and watch custom animations on the display.

This guide includes a complete wiring diagram for connecting the I2C LCD and joystick module to an Arduino Uno, a full parts list, firmware with custom character rendering for die faces, and step-by-step assembly instructions. Learn how to drive an LCD via I2C, handle button input, and animate graphics using custom bitmap characters.

Wiring diagram

Interactive · read-only
Wiring diagram for Stacker Game With LCD Display

Pan and zoom to explore the wiring. Remix the project to edit it in your own workspace.

Parts list

Bill of materials
ComponentQtyNotes
LCD 16x2 I2C116x2 character LCD display with I2C backpack
KY-023 Dual Axis Joystick Module1Dual-axis analog joystick breakout (PSP/PS2-style thumbstick) with two perpendicular 10 kOhm potentiometers and an integrated push-button. Outputs analog voltages on VRx and VRy proportional to stick position, plus an active-low digital switch (SW) that is pulled LOW when the stick is pressed. Operates 3.3 V to 5 V; on 5 V boards the VRx/VRy swing matches the wider ADC range. SW should be read with INPUT_PULLUP.

Assembly

4 steps
  1. Wire the I2C LCD

    Connect the LCD 16×2 I2C backpack to the Arduino Uno: VCC → 5V, GND → GND, SDA → A4, SCL → A5.

    • Tip: Make sure the I2C backpack is soldered firmly to the LCD header — a loose connection causes blank or garbled output.
  2. Wire the joystick button

    Connect the KY-023 joystick module: VCC → 5V, GND → GND. The SW (button) pin → D3. The VRx and VRy analog pins are wired but unused in this sketch (only the button is used).

    • Tip: The joystick button uses INPUT_PULLUP — no external resistor needed.
    • Tip: VRx → A0, VRy → A1 as before (keeping the module fully wired for future use).
  3. Adjust LCD contrast

    After flashing, if the LCD shows blank squares or nothing, turn the small blue trimmer potentiometer on the I2C backpack with a small flat-head screwdriver until the characters appear clearly.

    • Do not skip this step — new LCDs often have contrast set too high or too low from the factory.
  4. Roll the dice!

    The LCD shows 'DICE ROLLER — Press to roll!'. Press the joystick straight down (SW button). The die animates through random faces with increasing delay, then settles on a final face shown as a large pixel-art dot pattern. The result number (1–6) is also shown on the right side.

    • Tip: Press again anytime to roll again.
    • Tip: The die face uses custom pixel-art characters — each pip is a 2×2 block inside a bordered tile.

Firmware

Arduino
src/main.cppDeploy to device
#include <Arduino.h>
#include <Wire.h>
#include <LiquidCrystal_I2C.h>

#define BTN_PIN 3


// Forward declarations
void setPixel(byte bmp[8], int r, int c);
void drawPip(byte bmp[8], int r, int c);
void buildFace(int face, byte tiles[4][8]);
void showFace(int face);
void rollAnimation(int finalFace);
void showIdle();

LiquidCrystal_I2C lcd(0x27, 16, 2);

// ─────────────────────────────────────────────
//  Custom character bitmaps  (5×8)
//  Each die face is rendered across 4 char cells:
//  [LEFT-TOP][RIGHT-TOP]  ← row 0 of LCD
//  [LEFT-BOT][RIGHT-BOT]  ← row 1 of LCD
//  We need chars for: blank, top-pip, mid-pip, bot-pip, mid-single
// ─────────────────────────────────────────────

// Custom chars are fully built at runtime by buildFace().
// No static bitmaps needed — all 4 slots are reloaded per face.

// ─── Pip positions per die face ───────────────────────────────
// The 16×2 LCD shows the die as a big 4-cell block:
//   cells (col,row): [4,0][5,0][6,0][7,0]  top half  (8 chars wide = big die)
//                    [4,1][5,1][6,1][7,1]  bot half
// We'll use an 8-col × 2-row region (cols 4-11) for the die face.
// Die face = 8 chars wide, 2 chars tall → 40px wide × 16px tall pixel canvas.
// Each "section" of the die (top-left, top-right, mid-left, mid-right,
// bot-left, bot-right, centre) maps to character slots.

// ─── Simpler approach: 4-char wide × 2-char tall die ──────────
// Col 6,7,8,9 × rows 0,1  (centred on 16-col LCD)
// Each char = 5×8 px → die = 20px wide × 16px tall. Clean!
// We define 8 custom chars fresh for every face.

// Pip dot drawn inside a bordered tile:
//  dot at TOP-LEFT corner of cell
//  dot at TOP-RIGHT corner of cell
//  dot at BOTTOM-LEFT corner of cell
//  dot at BOTTOM-RIGHT corner of cell
//  dot at CENTRE of cell
//  blank (border only)

// For each face we produce 4 tiles (TL, TR, BL, BR).
// Pips are placed at the 4 corners and centre of the full face.
// Mapping of pip positions to cells:
//   pip TOP-LEFT     → TL cell, upper-left pixel group
//   pip TOP-RIGHT    → TR cell, upper-right pixel group
//   pip MID-LEFT     → BL cell, upper-left (mid of full face = top of bottom cell)
//   pip MID-RIGHT    → BR cell, upper-right
//   pip BOT-LEFT     → BL cell, lower-left
//   pip BOT-RIGHT    → BR cell, lower-right
//   pip CENTRE-TOP   → TR cell or TL cell centre (top half centre)
//   pip CENTRE-BOT   → BL/BR centre (bottom half centre)

// We draw each pip as a 2×2 block of pixels inside the cell.
// Cell layout (5 cols, 8 rows, border on outside):
//   row0: █████  (top border)
//   row1: █·L·R█  (pip-left at col1-2, pip-right at col3-4 if needed)
//   row2: █·····█
//   row3: █·C···█  (centre pip at col2)
//   row4: █·····█
//   row5: █·L·R·█
//   row6: █·····█
//   row7: █████  (bottom border)

// ─────────────────────────────────────────────────────────────
// We define 6 face configurations.
// Each face = bitmaps for slots 0..3 (TL, TR, BL, BR)
// and an optional centre pip overlay slot 4 (centre char).
// We'll use exactly 4 chars per face (slots 0-3).
// Cols 6,7,8,9 of the LCD display chars 0,1,2,3.

// Pip locations in 4-cell face (each cell 5w×8h, border included):
//  ┌────────┬────────┐
//  │  TL(0) │  TR(1) │   ← LCD row 0
//  ├────────┼────────┤
//  │  BL(2) │  BR(3) │   ← LCD row 1
//  └────────┴────────┘
// Full pip positions:
//   NW = TL cell, top-left area
//   NE = TR cell, top-right area
//   W  = BL cell, top-left area  (mid height of face = top of bottom cell)
//   E  = BR cell, top-right area
//   SW = BL cell, bot-left area
//   SE = BR cell, bot-right area
//   C  = centre → split: top half in TL/TR, bottom half in BL/BR

// pip pixel positions inside a cell (row, col) — 0-indexed, inside border:
//   top-area  = rows 1-2
//   bot-area  = rows 5-6
//   left-col  = cols 1-2
//   right-col = cols 3-4
//   centre-col= col 2 (rows 3-4)

// Helper: set pixel in a byte[8] bitmap
void setPixel(byte bmp[8], int r, int c) {
  if (r < 0 || r > 7 || c < 0 || c > 4) return;
  bmp[r] |= (1 << (4 - c));
}

// Draw a 2×2 pip block at (row,col) top-left inside cell
void drawPip(byte bmp[8], int r, int c) {
  setPixel(bmp, r,   c);
  setPixel(bmp, r,   c+1);
  setPixel(bmp, r+1, c);
  setPixel(bmp, r+1, c+1);
}

// ─────────────────────────────────────────────────────────────
// buildFace — pure dot patterns, no borders
//
// Die is drawn across 4 char cells arranged as:
//   [TL=0][TR=1]   ← LCD row 0   (top half of die)
//   [BL=2][BR=3]   ← LCD row 1   (bottom half of die)
//
// Full canvas = 10px wide × 16px tall (each cell 5w × 8h)
//
// Pip anchor points (each pip = 2×2 block):
//
//   ● NW  → tile 0, row 1, col 1   top-left
//   ● NE  → tile 1, row 1, col 2   top-right
//   ● ML  → tile 0, row 5, col 1   mid-left  (straddles tile 0/2 boundary)
//   ● MR  → tile 1, row 5, col 2   mid-right
//   ● SW  → tile 2, row 2, col 1   bottom-left
//   ● SE  → tile 3, row 2, col 2   bottom-right
//   ● C   → centre: col 2-3 of tile 0+1, rows 5-6 (top half)
//                   col 2-3 of tile 2+3, rows 1-2 (bottom half)
//
// Real die faces:
//   1:          C
//   2:  NW               SE
//   3:  NW       C       SE
//   4:  NW  NE   SW  SE
//   5:  NW  NE   C   SW  SE
//   6:  NW  NE   ML  MR  SW  SE
// ─────────────────────────────────────────────────────────────
void buildFace(int face, byte tiles[4][8]) {
  // Clear all 4 tiles
  for (int t = 0; t < 4; t++)
    for (int r = 0; r < 8; r++)
      tiles[t][r] = 0;

  // ── pip macros ───────────────────────────────────────────────
  // top-left pip
  #define PIP_NW  drawPip(tiles[0], 1, 1)
  // top-right pip
  #define PIP_NE  drawPip(tiles[1], 1, 2)
  // mid-left pip (bottom of top tile)
  #define PIP_ML  drawPip(tiles[0], 5, 1)
  // mid-right pip
  #define PIP_MR  drawPip(tiles[1], 5, 2)
  // bottom-left pip (top of bottom tile)
  #define PIP_SW  drawPip(tiles[2], 2, 1)
  // bottom-right pip
  #define PIP_SE  drawPip(tiles[3], 2, 2)
  // centre pip — sits at the junction of all 4 tiles
  //   in the top tiles:    cols 3-4 of tile0 + cols 0-1 of tile1, rows 5-6
  //   in the bottom tiles: cols 3-4 of tile2 + cols 0-1 of tile3, rows 1-2
  #define PIP_C { \
    setPixel(tiles[0], 5, 3); setPixel(tiles[0], 6, 3); \
    setPixel(tiles[1], 5, 0); setPixel(tiles[1], 6, 0); \
    setPixel(tiles[2], 1, 3); setPixel(tiles[2], 2, 3); \
    setPixel(tiles[3], 1, 0); setPixel(tiles[3], 2, 0); }

  switch (face) {
    case 1:
      // . (centre only)
      //
      //     .
      //
      PIP_C;
      break;

    case 2:
      // .        (top-left)
      //
      //        . (bottom-right)
      PIP_NW;
      PIP_SE;
      break;

    case 3:
      // .        (top-left)
      //    .     (centre)
      //        . (bottom-right)
      PIP_NW;
      PIP_C;
      PIP_SE;
      break;

    case 4:
      // .   .    (top-left, top-right)
      //
      // .   .    (bottom-left, bottom-right)
      PIP_NW; PIP_NE;
      PIP_SW; PIP_SE;
      break;

    case 5:
      // .   .    (top-left, top-right)
      //    .     (centre)
      // .   .    (bottom-left, bottom-right)
      PIP_NW; PIP_NE;
      PIP_C;
      PIP_SW; PIP_SE;
      break;

    case 6:
      // .   .    (top-left,  top-right)
      // .   .    (mid-left,  mid-right)
      // .   .    (bot-left,  bot-right)
      PIP_NW; PIP_NE;
      PIP_ML; PIP_MR;
      PIP_SW; PIP_SE;
      break;
  }

  #undef PIP_NW
  #undef PIP_NE
  #undef PIP_ML
  #undef PIP_MR
  #undef PIP_SW
  #undef PIP_SE
  #undef PIP_C
}

// Display a die face (1-6) centred on the LCD.
//
// Die block = 4 char cells arranged as:
//   cols 6,7 row 0 → tile 0 (TL), tile 1 (TR)
//   cols 6,7 row 1 → tile 2 (BL), tile 3 (BR)
//
// Each tile is 5px wide × 8px tall → die = 10px wide × 16px tall.
// Surrounded by blank space on the LCD.
void showFace(int face) {
  byte tiles[4][8];
  buildFace(face, tiles);

  // Load tiles into custom char slots 0–3
  for (int i = 0; i < 4; i++)
    lcd.createChar(i, tiles[i]);

  lcd.clear();

  // ── top row: TL at col 6, TR at col 7 ────────────────────
  lcd.setCursor(6, 0); lcd.write((uint8_t)0);  // TL
  lcd.setCursor(7, 0); lcd.write((uint8_t)1);  // TR

  // ── bottom row: BL at col 6, BR at col 7 ─────────────────
  lcd.setCursor(6, 1); lcd.write((uint8_t)2);  // BL
  lcd.setCursor(7, 1); lcd.write((uint8_t)3);  // BR

  // ── face number on the right side ─────────────────────
  lcd.setCursor(13, 0); lcd.print("  ");
  lcd.setCursor(12, 0); lcd.print("Rolled:");
  lcd.setCursor(14, 1); lcd.print("  ");
  lcd.setCursor(14, 1); lcd.print(face);
}

// ─── Animation: flash random faces, slowing to a stop ────────
void rollAnimation(int finalFace) {
  // Phase 1: fast random flashes
  int delays[] = {60, 60, 70, 70, 80, 90, 100, 120, 150, 180, 220, 280, 350, 430, 520};
  int steps = 15;

  int shown = 0;
  for (int i = 0; i < steps; i++) {
    int f;
    do { f = random(1, 7); } while (f == shown); // avoid repeating same face
    shown = f;
    showFace(f);
    delay(delays[i]);
  }

  // Final face
  showFace(finalFace);

  // Settle briefly on the final face
  delay(200);
}

// ─── Idle screen ──────────────────────────────────────────────
void showIdle() {
  lcd.clear();
  lcd.setCursor(2, 0); lcd.print("* DICE ROLLER *");
  lcd.setCursor(1, 1); lcd.print("Press to roll!");
}

// ─── Setup & Loop ─────────────────────────────────────────────
bool lastBtn = HIGH;
unsigned long lastDebounce = 0;
bool rolling = false;

void setup() {
  randomSeed(analogRead(A2)); // float pin for entropy

  pinMode(BTN_PIN, INPUT_PULLUP);

  lcd.init();
  lcd.backlight();
  showIdle();
}

void loop() {
  bool btn = digitalRead(BTN_PIN);

  // Debounce: detect falling edge (press)
  if (lastBtn == HIGH && btn == LOW) {
    if (millis() - lastDebounce > 200) {
      lastDebounce = millis();

      // Roll!
      int result = random(1, 7);
      rollAnimation(result);

      // Wait for button release after the roll
      while (digitalRead(BTN_PIN) == LOW) delay(10);

      // Wait for next joystick click to proceed
      while (true) {
        bool b = digitalRead(BTN_PIN);
        if (b == LOW) {
          delay(200); // debounce
          while (digitalRead(BTN_PIN) == LOW) delay(10);
          break;
        }
        delay(10);
      }

      showIdle();
    }
  }

  lastBtn = btn;
  delay(10);
}

“Deploy to device” opens this project in Schematik, where you can flash it to your board over USB.

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