Tetris Wall Art for Arduino

Updated on 2024-03-04

Display automated (optionally controllable) Tetris on an LCD or Neopixel panel

Introduction

I'm not even sure why I made this, other than simply for the "cool factor". It's a barebones Tetris implementation on an Arduino device, controllable from a PC via a serial connection, or (badly) automated to run on its own. It was primarily built for neopixel displays but I prototyped it on a TTGO T1 so I made the whole codebase scalable and otherwise adaptable to different displays.

Background

My graphics library does a lot of the heavy lifting. In order to save space, I use "microbitmaps" to store the pieces and the board. The pieces are monochrome bitmaps, and the board is a 16 color bitmap where each indexed color maps to an EGA color value - EGA being an old PC graphics standard.

They are "microbitmaps" as opposed to bitmaps because I only allocate one pixel for each Tetris "square" (where 4 squares make up a "piece"). When it's drawn, the bitmaps are essentially just referenced to determine the actual graphics to draw on the screen.

Despite using monochrome, indexed color, and your display's native color resolution and model, it's easy to manipulate any of these bitmaps or displays because my graphics library excels at this very thing.

A Tetris game consists of a board, a current piece, and a next piece. The current piece is the one that is falling. The next piece is the next one up, in case you want to develop this into a full fledged traditional Tetris clone, and the board holds all of the existing fallen piece information.

The board size is computed based on the screen dimensions given. Depending on the display, this may not yield an optimal playing field for Tetris, but it is suitable for wall art. The best displays are tall and narrow, like the TTGO T-Display T1.

Understanding the Code

The Firmware

main.cpp

Driving the game is simple, as seen in main.cpp:

#include <Arduino.h>
#include <gfx.hpp>
#include "config.h"
#include "interface.h"
#include "tetris.hpp"
// import the htcw_gfx primary namespace
using namespace gfx;

// create a tetris board that can draw to our panel
using tetris_t = tetris<typename panel_t::pixel_type>;
tetris_t game;
void setup()
{
    Serial.begin(115200);
#ifdef M5STACK_CORE2
    // m5 stack core2 requires
    // power management chip
    // to be tickled
    power.initialize();
#endif
    panel.initialize();
    // set up the game and start it
    game.dimensions(panel.dimensions());
    game.restart();
}

void loop()
{
    // timeout value for connection detection
    static uint32_t watchdog_ts = 0;
    // any serial data?
    int cmd = Serial.read();
    if(-1!=cmd) { // process it
        // start/restart the connection watchdog
        watchdog_ts = millis();
        // move according to serial input
        switch((CMD_ID)cmd) {
            case CMD_MOVE_LEFT:
                game.move_left();
                break;
            case CMD_MOVE_RIGHT:
                game.move_right();
                break;
            case CMD_ROTATE_LEFT:
                game.rotate_left();
                break;
            case CMD_ROTATE_RIGHT:
                game.rotate_right();
                break;
            case CMD_DROP:
                game.drop();
                break;
            default:
                break;
        }
    }
    // if we haven't had serial input for 1 second
    // go back to automated mode
    if(watchdog_ts!=0 && millis()>watchdog_ts+1000) {
        watchdog_ts = 0;
    }
    // check if the game board is dirty
    if(game.needs_draw()) {
        // if so, suspend all display
        // (only does anything on supporting devices)
        draw::suspend(panel);
        // draw the game at (0,0)
        game.draw(panel,point16::zero());
        // resume display
        draw::resume(panel);
    }
    // if not connected, just move
    // back and forth and rotate
    if(!watchdog_ts) {
        static uint32_t ts = 0;
        static bool delta = true;
        if(millis()>ts+game.advance_time()/2) {
            ts = millis();
            if(delta) {
                if(!game.move_right()) {
                    delta = false;
                    game.move_left();
                }
                game.rotate_left();
            } else {
                if(!game.move_left()) {
                    delta = true;
                    game.move_right();
                }
                game.rotate_right();
            }
        }
    }
    // pump the game loop
    game.update();
    // if it's not running anymore
    // that means game over
    if(!game.running()) {
        // just restart it
        game.restart();
    }
}

I just pasted the file in its entirety. The comments should make it clear. Obviously, this isn't as interesting as the game engine itself, but it gives us a place to start.

Roughly, it starts out by initializing the display and the game. It also initializes the power chip on the M5 Stack Core2 if applicable, as that device requires it.

In loop(), it checks for serial input, and moves the game piece accordingly, drawing the game as necessary, and if we're not connected, moving and rotating the pieces in a regular fashion.

Finally, we pump the game loop, and restart the game if it ends. During this process, watchdog_ts is used to track whether connected to (non-zero) or disconnected (zero) from the PC application.

tetris.hpp

This file contains the meat of the game engine logic. The game size is computed based on the screen's dimensions. From there, the game area is laid out in square tiles, where 4 squares make a Tetris piece. Each square is either black or one of several EGA colors - all 16 colors except black, grays and white are used. To give you an idea of this, let's take a look at the routine to draw a square tile:

template<typename Destination, typename PixelType>
void draw_square(Destination& destination, const gfx::rect16& bounds, PixelType col) {
    // get some x11 colors in HSL 24 bit
    using x11 = gfx::color<gfx::hsl_pixel<24>>;
    // get black and white and convert them to the target pixel type
    constexpr static const PixelType white =
        gfx::convert<gfx::hsl_pixel<24>,PixelType>(x11::white);
    constexpr static const PixelType black =
        gfx::convert<gfx::hsl_pixel<24>,PixelType>(x11::black);
    const gfx::rect16 b = bounds.normalize();
    // if our dimensions are small enough, just do a simple fill
    if((b.x2-b.x1+1)<3 || (b.y2-b.y1+1)<3) {
        destination.fill(b,col);
        return;
    }
    // otherwise, draw a 3d tile
    const gfx::rect16 rb = b.inflate(-1,-1);
    destination.fill(rb,col);
    // make the lighter color
    PixelType px2 = col.blend(white,0.5f);
    destination.fill(gfx::rect16(b.x1,b.y1,b.x2-1,b.y1),px2);
    destination.fill(gfx::rect16(b.x2,b.y1,b.x2,b.y2-1),px2);
    // make the darker color
    px2 = col.blend(black,0.5f);
    destination.fill(gfx::rect16(b.x2,b.y2,b.x1+1,b.y2),px2);
    destination.fill(gfx::rect16(b.x1,b.y2,b.x1,b.y1+1),px2);
}

Basically, if the size is less than 3x3, it draws a simple tile with no 3D embossing. Otherwise, it draws a simple 3D tile. Note that we went to the destination methods directly instead of using the gfx::draw class. We didn't need draw's fancy capabilities - just the basics, so going straight to the destination panel and acting on it directly is marginally more efficient. This is a core routine which will be used to draw each of the tiles on the board, including the game piece itself. Note that it's tucked away inside an empty namespace declaration so that it's private to this header.

The piece itself handles storing its color, location, and shape as well as handling rotation, hit testing, and creation of the various core shapes. The piece works primarily by storing a tiny monochrome micro-bitmap in a 2 byte buffer (of which there is space left over). Rotation modifies the bitmap and the dimensions.

Here's the implementation for creating one of the pieces (in tetris.cpp):

piece piece::create_T() {
    piece result;
    result.m_location = point16::zero();
    result.m_dimensions = {3,2};
    data_type bmp(result.m_dimensions,result.m_data);
    bmp.clear(bmp.bounds());
    bmp.point({0,0},piece_set);
    bmp.point({1,0},piece_set);
    bmp.point({2,0},piece_set);
    bmp.point({1,1},piece_set);
    return result;
}

What it has done is wrapped the piece's uint8_t m_data[2] array with a bitmap (data_type). This is a very lightweight operation. Once that's done it's cleared, and then the appropriate points are set.

The board is private to this header, and keeps a 16-color micro-bitmap of the board dimensions to hold the square tiles that have already fallen. It provides mechanisms for memory management of the bitmap, hit testing, the addition of pieces, and the removal of full rows.

The tetris class exposes the primary game functionality. It includes mechanisms for moving and rotating the piece, tracking the rows cleared (for use with computing a score), driving the game itself and timing everything. It also draws the screen to a given draw destination.

neopixel_panel.hpp

Neopixels are neat. You can get panels of them or strings of them, but here, we use the panel configuration so that we can lay out the Tetris board. This file knows nothing of Tetris but it knows how to drive Neopixels on an ESP32. You should be able to adapt it to use a 3rd party library like FastLED if you want to support more platforms aside from an ESP32. Most of the code will remain unchanged. Only setting and retrieving the LED values will be different. The class's primary purpose is mapping coordinates and adapting an interface such that an RMT driver can be used to drive a string of LEDs that are in turn laid out on a board. It then exposes all of this as an htcw_gfx draw target so that it can be used with htcw_gfx drawing operations.

The PC Companion Application

This application is very simple. Its sole responsibility is to allow you to connect to the IoT device's primary serial and then it translates keystrokes to commands to send across the serial wire to move the pieces. It also pings the firmware periodically so that the firmware knows when there's a connection.

The meat is entirely in Main.cs.

Points of Interest

I left a lot of room for improvement, such as bidirectional communication with the PC app to display the Tetris board in both places and keep a score, etc. Another nice feature would be WiFi support. This is primarily for wall art rather than playing an actual game. Get a very large Neopixel panel for best results.

History

  • 4th March, 2024 - Initial submission