Stacker on an ESP32-C3
The arcade stacker game on two cascaded MAX7219 matrices: the row slides, the button drops it, the overhang is cut off. The whole build, an ESP32-C3 and 3 more parts, runs here in your browser on the firmware below; open it in the editor to change the wiring or the code and run it again.
AdvancedRuns in your browser. Free, and no account needed.
The code
The firmware exactly as the editor opens it. Change a line there and press Run: it compiles in the browser.
// Stacker: the arcade cabinet, on two cascaded 8x8 matrices and one button.
//
// Wiring, board to modules:
// DIN -> GPIO 7, into the top module only
// CLK -> GPIO 5 and CS -> GPIO 6, shared by both modules
// the top module's DOUT -> the bottom module's DIN: the cascade
// VCC -> 5V, GND -> GND
// a 30 mm arcade button from GPIO 3 to GND (the pin's own pull-up holds it high)
//
// A MAX7219 holds eight digit registers and scans them out itself; on a matrix
// board those eight registers are the eight rows and the eight segment outputs
// are the columns, bit 7 leftmost. So the picture is eight bytes, and drawing
// is writing a row register.
//
// The two modules stand one above the other, which makes the play field eight
// columns wide and sixteen rows tall. They are daisy-chained: the data line
// reaches only the top module, whose DOUT feeds the bottom module's DIN, and
// the clock and the one chip select go to both. A MAX7219's DOUT is the far
// end of its own sixteen-bit shift register, so thirty-two clocks with CS low
// leave the *first* packet sent standing in the *second* chip. Every write is
// therefore a pair: the bottom module's packet first, the top module's behind
// it, and one CS pulse latches both.
//
// The game is the cabinet's: a row of blocks slides left and right, the button
// drops it, and whatever hangs over the row below is cut off. The block starts
// three wide, narrows to two at the MINOR PRIZE line and to one near the top,
// and every level is a little quicker than the one under it. Trim it to nothing
// and the stack flashes and the game starts again.
//
// With nobody pressing anything for four seconds the board plays itself: it
// aims at the row below and lands it, except that now and then it deliberately
// aims one column off, which is what makes the stack narrow and, sooner or
// later, collapse. One real press takes the game straight back.
const uint8_t DIN_PIN = 7;
const uint8_t CLK_PIN = 5;
const uint8_t CS_PIN = 6;
const uint8_t BUTTON_PIN = 3;
/* Registers, from the data sheet's table 2. */
const uint8_t REG_DIGIT0 = 0x01;
const uint8_t REG_DECODE_MODE = 0x09;
const uint8_t REG_INTENSITY = 0x0a;
const uint8_t REG_SCAN_LIMIT = 0x0b;
const uint8_t REG_SHUTDOWN = 0x0c;
const uint8_t REG_DISPLAY_TEST = 0x0f;
const uint8_t COLUMNS = 8;
const uint8_t LEVELS = 16;
/** Partway up: the block narrows to two here, and the line is worth a flash. */
const uint8_t MINOR_LEVEL = 7;
/** The top of the cabinet. */
const uint8_t MAJOR_LEVEL = LEVELS - 1;
/** Milliseconds a block takes to move one column, at the floor and at the top. */
const uint32_t SLOW_STEP = 210;
const uint32_t FAST_STEP = 70;
/** How long the board waits for a person before it starts playing itself. */
const uint32_t ATTRACT_AFTER_MS = 4000;
/** The picture: one byte a row, row 0 at the top, bit 7 the leftmost column. */
static uint8_t frame_[LEVELS];
/** One bit per column, level 0 at the floor. */
static uint8_t stack_[LEVELS];
static uint8_t level_; // the level the moving block is on
static uint8_t width_; // how many columns wide it is
static int8_t pos_; // its leftmost column
static int8_t dir_; // +1 moving right, -1 moving left
static uint32_t step_ms_;
static uint32_t last_step_;
static uint32_t last_press_;
static bool attract_;
static bool held_;
/** Attract mode only: how many columns off the perfect drop this level aims. */
static int8_t aim_error_;
/* ---- the chain ------------------------------------------------------- */
/**
* One packet into the chain: sixteen bits, most significant first: four
* don't-care bits, the four-bit register address, the data byte. The chip
* shifts on every rising edge of CLK whatever CS is doing; CS is what latches.
*/
static void shift_packet(uint8_t address, uint8_t data)
{
uint16_t packet = (uint16_t)(((uint16_t)(address & 0x0f) << 8) | data);
for (int8_t bit = 15; bit >= 0; bit--) {
digitalWrite(CLK_PIN, LOW);
digitalWrite(DIN_PIN, (packet >> bit) & 1u ? HIGH : LOW);
digitalWrite(CLK_PIN, HIGH);
}
}
/**
* One register in each module of the chain, latched together: CS low, the
* bottom module's sixteen bits, the top module's sixteen bits, CS high. The
* bottom module's packet goes first because it has the furthest to travel:
* it is pushed straight through the top chip and out of its DOUT.
*/
static void write_chain(uint8_t address, uint8_t bottom, uint8_t top)
{
digitalWrite(CS_PIN, LOW);
shift_packet(address, bottom);
shift_packet(address, top);
digitalWrite(CS_PIN, HIGH);
}
/** The same register, the same value, in both modules. */
static void write_both(uint8_t address, uint8_t value)
{
write_chain(address, value, value);
}
/** The frame buffer to the glass: rows 0 to 7 are the top module, rows 8 to 15
* the bottom one, and each pair of rows goes out in one CS pulse. */
static void show()
{
for (uint8_t digit = 0; digit < 8; digit++) {
write_chain((uint8_t)(REG_DIGIT0 + digit), frame_[8 + digit], frame_[digit]);
}
}
/**
* Wake both chips. Every register comes up zero at power-on (shut down, one
* row scanned, no brightness), which is why a real board shows nothing until
* this runs. Shutdown reaching the far chip is the whole test of the cascade:
* if the chain is a clock out of step the bottom half stays dark.
*/
static void begin_panel(uint8_t intensity)
{
pinMode(CS_PIN, OUTPUT);
pinMode(CLK_PIN, OUTPUT);
pinMode(DIN_PIN, OUTPUT);
digitalWrite(CS_PIN, HIGH);
digitalWrite(CLK_PIN, LOW);
write_both(REG_DISPLAY_TEST, 0x00);
write_both(REG_DECODE_MODE, 0x00); // a matrix is raw segments, not digits
write_both(REG_SCAN_LIMIT, 0x07); // every row
write_both(REG_INTENSITY, (uint8_t)(intensity & 0x0f));
write_both(REG_SHUTDOWN, 0x01); // 0 is shut down, 1 is running
}
/* ---- the picture ----------------------------------------------------- */
static void clear_frame()
{
for (uint8_t row = 0; row < LEVELS; row++) {
frame_[row] = 0;
}
}
/** One pixel, in play-field coordinates: level 0 is the floor. */
static void plot(uint8_t column, uint8_t level)
{
frame_[LEVELS - 1 - level] |= (uint8_t)(0x80u >> column);
}
/** A whole row of the field. */
static void plot_row(uint8_t level)
{
frame_[LEVELS - 1 - level] = 0xff;
}
/** The stack into the buffer, and the moving block with it if asked. */
static void paint(bool with_block)
{
clear_frame();
for (uint8_t level = 0; level < LEVELS; level++) {
for (uint8_t column = 0; column < COLUMNS; column++) {
if (stack_[level] & (1u << column)) {
plot(column, level);
}
}
}
if (with_block) {
for (uint8_t i = 0; i < width_; i++) {
plot((uint8_t)(pos_ + i), level_);
}
}
}
static void blank()
{
clear_frame();
show();
}
/* ---- the game -------------------------------------------------------- */
/** The moving block as a column mask. */
static uint8_t block_mask()
{
uint8_t mask = 0;
for (uint8_t i = 0; i < width_; i++) {
mask = (uint8_t)(mask | (1u << (pos_ + i)));
}
return mask;
}
static uint8_t count_bits(uint8_t mask)
{
uint8_t n = 0;
for (uint8_t i = 0; i < 8; i++) {
if (mask & (1u << i)) n++;
}
return n;
}
static uint8_t lowest_bit(uint8_t mask)
{
for (uint8_t i = 0; i < 8; i++) {
if (mask & (1u << i)) return i;
}
return 0;
}
/** How wide a block may be at this height, before the row below trims it. */
static uint8_t width_at(uint8_t level)
{
if (level >= MAJOR_LEVEL - 2) return 1;
if (level >= MINOR_LEVEL) return 2;
return 3;
}
/** A block one level up is a little quicker. */
static uint32_t step_for(uint8_t level)
{
return SLOW_STEP - ((SLOW_STEP - FAST_STEP) * level) / MAJOR_LEVEL;
}
/**
* Where the block has to stop to keep every column it has: the left edge of
* the row under it, or the middle of the field for the first one.
*/
static int8_t perfect_stop()
{
if (level_ == 0) return (int8_t)((COLUMNS - width_) / 2);
return (int8_t)lowest_bit(stack_[level_ - 1]);
}
/**
* Pick this level's attract-mode aim: mostly perfect, now and then one column
* off. A miss with two columns left narrows the block; a miss with one left
* ends the game, so that one is rarer.
*/
static void choose_aim()
{
aim_error_ = 0;
long odds = width_ >= 2 ? 4 : 7;
if (random(odds) == 0) {
aim_error_ = (random(2) == 0) ? -1 : 1;
}
}
static void start_level(uint8_t level)
{
level_ = level;
uint8_t most = width_at(level);
if (width_ > most) width_ = most;
// Start from the end furthest from the perfect stop, so every level is a
// run across the field rather than a block that is already home.
int8_t last = (int8_t)(COLUMNS - width_);
bool stop_is_left = perfect_stop() * 2 <= last;
pos_ = stop_is_left ? last : 0;
dir_ = stop_is_left ? -1 : 1;
step_ms_ = step_for(level);
last_step_ = millis();
choose_aim();
}
/** Both prize lines, twice, so it is plain where the game is going. */
static void show_prize_lines()
{
for (uint8_t flash = 0; flash < 2; flash++) {
clear_frame();
plot_row(MINOR_LEVEL);
plot_row(MAJOR_LEVEL);
show();
delay(170);
blank();
delay(140);
}
}
static void new_game()
{
for (uint8_t level = 0; level < LEVELS; level++) {
stack_[level] = 0;
}
width_ = width_at(0);
show_prize_lines();
start_level(0);
}
/** Trimmed to nothing: flash what was built, then start again. */
static void game_over()
{
for (uint8_t flash = 0; flash < 3; flash++) {
paint(false);
show();
delay(150);
blank();
delay(150);
}
delay(400);
new_game();
}
/** The MINOR PRIZE line: its row blinks over the stack, and play goes on. */
static void minor_prize()
{
for (uint8_t flash = 0; flash < 3; flash++) {
paint(false);
plot_row(MINOR_LEVEL);
show();
delay(110);
paint(false);
show();
delay(110);
}
}
/** The top of the cabinet: the field fills from the floor up, twice. */
static void major_prize()
{
for (uint8_t sweep = 0; sweep < 2; sweep++) {
clear_frame();
for (uint8_t level = 0; level < LEVELS; level++) {
plot_row(level);
show();
delay(35);
}
blank();
delay(180);
}
new_game();
}
/**
* Drop the block: keep only the columns standing on the row below, and if that
* is none of them the game is over. The floor catches anything.
*/
static void drop()
{
uint8_t landed = block_mask();
if (level_ > 0) {
landed = (uint8_t)(landed & stack_[level_ - 1]);
}
if (landed == 0) {
game_over();
return;
}
stack_[level_] = landed;
width_ = count_bits(landed);
pos_ = (int8_t)lowest_bit(landed);
paint(false);
show();
if (level_ == MAJOR_LEVEL) {
major_prize();
return;
}
if (level_ == MINOR_LEVEL) {
minor_prize();
}
start_level((uint8_t)(level_ + 1));
}
/** The column the attract player is waiting for. */
static int8_t attract_target()
{
int8_t want = (int8_t)(perfect_stop() + aim_error_);
if (want < 0) want = 0;
if (want > (int8_t)(COLUMNS - width_)) want = (int8_t)(COLUMNS - width_);
return want;
}
/* ---- the sketch ------------------------------------------------------ */
void setup()
{
Serial.begin(115200);
Serial.println("Mokxi ESP32-C3: stacker, eight wide and sixteen tall");
pinMode(BUTTON_PIN, INPUT_PULLUP);
randomSeed(micros());
begin_panel(6);
last_press_ = millis();
attract_ = false;
held_ = false;
new_game();
}
void loop()
{
uint32_t now = millis();
// The button is active low, and only the edge counts: holding it down drops
// one block, not every block.
bool down = digitalRead(BUTTON_PIN) == LOW;
if (down && !held_) {
held_ = true;
last_press_ = now;
attract_ = false;
drop();
return;
}
if (!down) {
held_ = false;
}
if (!attract_ && now - last_press_ >= ATTRACT_AFTER_MS) {
attract_ = true;
}
if (now - last_step_ < step_ms_) {
// Nothing to do yet. Sleeping rather than spinning costs the simulator
// nothing and still reads the button five hundred times a second.
delay(2);
return;
}
last_step_ = now;
pos_ = (int8_t)(pos_ + dir_);
if (pos_ <= 0) {
pos_ = 0;
dir_ = 1;
} else if (pos_ + (int8_t)width_ >= (int8_t)COLUMNS) {
pos_ = (int8_t)(COLUMNS - width_);
dir_ = -1;
}
paint(true);
show();
if (attract_ && pos_ == attract_target()) {
drop();
}
}
Parts list
5 parts, plus the jumper wires. Every one is in the editor's parts bin.
- 1 × ESP32-C3-DevKitM-1
- 1 × Half breadboard
- 1 × Arcade button
- 2 × LED matrix, 8x8
How it is wired
7 connections, pin by pin, read from the circuit itself. Each line is one set of pins joined together, by a jumper wire or a breadboard strip.
- ESP32-C3-DevKitM-1 pin 3; Arcade button pin 2
- Ground: ESP32-C3-DevKitM-1 pin GND; Arcade button pin 1; LED matrix, 8x8 (1) pin GND; LED matrix, 8x8 (2) pin GND
- ESP32-C3-DevKitM-1 pin 5; LED matrix, 8x8 (1) pin CLK; LED matrix, 8x8 (2) pin CLK
- ESP32-C3-DevKitM-1 pin 6; LED matrix, 8x8 (1) pin CS; LED matrix, 8x8 (2) pin CS
- ESP32-C3-DevKitM-1 pin 7; LED matrix, 8x8 (1) pin DIN
- ESP32-C3-DevKitM-1 pin 5V; LED matrix, 8x8 (1) pin VCC; LED matrix, 8x8 (2) pin VCC
- LED matrix, 8x8 (1) pin DOUT; LED matrix, 8x8 (2) pin DIN
Change it and keep it
Open it in the editor, change the circuit or the code, and keep your version in a free account.