Have you ever wanted to hang Tetris on your wall? Now with a Neopixel panel and an ESP32, or some other gadget and an LCD, you can.
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"
using namespace gfx;
using tetris_t = tetris<typename panel_t::pixel_type>;
tetris_t game;
void setup()
{
Serial.begin(115200);
#ifdef M5STACK_CORE2
power.initialize();
#endif
panel.initialize();
game.dimensions(panel.dimensions());
game.restart();
}
void loop()
{
static uint32_t watchdog_ts = 0;
int cmd = Serial.read();
if(-1!=cmd) { watchdog_ts = millis();
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(watchdog_ts!=0 && millis()>watchdog_ts+1000) {
watchdog_ts = 0;
}
if(game.needs_draw()) {
draw::suspend(panel);
game.draw(panel,point16::zero());
draw::resume(panel);
}
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();
}
}
}
game.update();
if(!game.running()) {
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) {
using x11 = gfx::color<gfx::hsl_pixel<24>>;
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((b.x2-b.x1+1)<3 || (b.y2-b.y1+1)<3) {
destination.fill(b,col);
return;
}
const gfx::rect16 rb = b.inflate(-1,-1);
destination.fill(rb,col);
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);
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