DEV Community

Cover image for Coding a Retro Space Shooter and Tetris into my Digital Swiss Army Knife
Donato Maglie
Donato Maglie

Posted on

Coding a Retro Space Shooter and Tetris into my Digital Swiss Army Knife

In the previous posts, I detailed how I transformed an ESP32-S3 into a multitasking network tool, featuring a BadUSB interpreter, a PCAP packet sniffer, and a real-time Deauth Radar running on FreeRTOS.

But a multi-tool with an LCD screen and hardware buttons is practically begging for some entertainment. I wanted to test the limits of the hardware and the LVGL graphics library by running fast-paced games on the same microcontroller that handles the network diagnostics.

This post covers how I implemented a native Tetris clone and a full Roguelite Space Shooter in C, and how I solved the biggest hardware limitation of them all: designing complex gameplay for a device that only has two physical buttons.

The Hardware Constraint: Playing Tetris with Two Buttons

A standard Tetris game requires at least four inputs: move left, move right, rotate, and drop. The ESP32 stick I am using has exactly two inputs: a front button and a side button.

To make the game playable, I had to redesign the core mechanics around an auto-sweep system. Instead of manually moving the piece left and right, the active piece automatically shifts one block horizontally every 200 milliseconds. When it hits the wall or another block, it reverses direction.

This reduced the required player inputs to just two actions: rotate and drop.

Here is how the auto-sweep logic handles the wall collisions in C:

// Auto Sweep Left-Right
if (now - last_auto_x > 200) {
    if (!check_collision(current_piece, current_rot, current_x + auto_x_dir, current_y)) {
        current_x += auto_x_dir;
    } else {
        // Hit a boundary, reverse direction
        auto_x_dir = -auto_x_dir;
        if (!check_collision(current_piece, current_rot, current_x + auto_x_dir, current_y)) {
            current_x += auto_x_dir;
        }
    }
    last_auto_x = now;
    lv_obj_invalidate(t_scr);
}
Enter fullscreen mode Exit fullscreen mode

With movement handled autonomously, I mapped the side button to Rotation and the front button to Drop. To squeeze more functionality out of the limited hardware, I implemented timing logic in the software debouncer. A single click of the side button rotates the piece, but a rapid double-click pauses the game. A long hold of the front button triggers a soft drop, while a quick double-click forces a hard drop to lock the piece instantly.

Vector Graphics and State Machines: The Space Shooter

Tetris was a good proof of concept, but I wanted to push the rendering engine further. I built a vertical Space Shooter featuring 30 waves of enemies, 3 boss fights, an in-game shop, and a Roguelite upgrade system tracking health, damage, multi-shot, and bullet bounce.

The biggest challenge in a bullet hell shooter on an embedded device is memory management. Dynamically allocating and freeing memory for dozens of bullets and enemies every frame will quickly fragment the ESP32's heap and cause a crash.

To solve this, I pre-allocated everything using static arrays of structs. The game engine maintains a strict object pool: 16 enemies, 30 player bullets, and 40 enemy bullets. When a weapon is fired, the engine iterates through the pool to find the first inactive bullet, updates its coordinates, and flags it as active.

#define MAX_E_BULLETS 40
typedef struct { lv_obj_t * obj; bool active; int x, y; int dx, dy; } e_bullet_t;
static e_bullet_t e_bullets[MAX_E_BULLETS];

static void fire_enemy_bullet(int x, int y, int dx, int dy, int w, int h, uint32_t color) {
    for (int i = 0; i < MAX_E_BULLETS; i++) {
        if (!e_bullets[i].active) {
            e_bullets[i].active = true;
            e_bullets[i].x = x; 
            e_bullets[i].y = y;
            e_bullets[i].dx = dx; 
            e_bullets[i].dy = dy;

            if (!e_bullets[i].obj) {
                e_bullets[i].obj = lv_obj_create(game_scr);
                lv_obj_set_style_border_width(e_bullets[i].obj, 0, 0);
            }
            lv_obj_set_size(e_bullets[i].obj, w, h);
            lv_obj_set_style_bg_color(e_bullets[i].obj, lv_color_hex(color), 0);
            lv_obj_clear_flag(e_bullets[i].obj, LV_OBJ_FLAG_HIDDEN);
            lv_obj_set_pos(e_bullets[i].obj, x, y);
            break;
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Instead of using heavy bitmaps for the enemy sprites, which would consume valuable flash storage, I used LVGL lines to draw vector graphics. Each enemy type (drones, tanks, bosses) is defined by an array of 2D coordinates. The engine simply draws lines connecting these points. This approach takes almost zero RAM and allows the game to scale visually without any performance penalty.

Bypassing Hardware Interrupts

Handling physical button presses in a fast-paced 20ms game loop can be problematic due to hardware bouncing. You press the button once, and the microcontroller reads five rapid clicks.

Rather than relying on hardware interrupts which can overwhelm the FreeRTOS queue when a button is mashed, I handled debouncing entirely in software within the main game timer. The loop checks the raw GPIO states and waits for a 30-millisecond stable state before registering the input as a valid action. This keeps the inputs crisp and prevents accidental double-rotations in Tetris or misfires in the Space Shooter.

Adding games to a security multi-tool might seem counterproductive at first glance. However, it served as an excellent stress test for RTOS task management and UI rendering. The same techniques used to build an object pool for bullets were directly applicable to optimizing the packet sniffer queues. Getting playable games running on a limited interface forced a level of UX optimization that ultimately improved the design of the entire Digital Swiss Army Knife.

Top comments (0)