Built-in project · ESP32-C3-DevKitM-1

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.

Teaching? Assign it to your class in one click.

Advanced About 30 minutes to build and run. Runs in your browser. Free, and no account needed.

The parts retail for about $17.50. On Mokxi they're free.

Step by step, with the wiring and the common mistakes: Arduino Stacker Arcade Game on Two LED Matrices

The circuit runs here once the page has loaded.

What you'll learn

  • Daisy-chaining two MAX7219 chips on one data line
  • Bit-banging a 16-bit packet: register address and data
  • Storing a picture as one byte per row
  • Bitmasks for blocks, the stack and overhang
  • Edge detection, so one press is one drop
  • Difficulty: Advanced
  • Time: About 30 minutes
  • Board: ESP32-C3-DevKitM-1
  • Parts: 5

How it works

Two 8x8 MAX7219 matrices stand one above the other to make a play field 8 columns wide and 16 rows tall. At power-on a block is already sliding left and right, and the board plays itself until you press the button. Press the button on pin 3 and it drops onto the row below. Any overhang is cut off and falls.

The block starts 3 columns wide, narrows to 2 about halfway up, and to 1 near the top. Each level moves a little faster than the last. Land a block on the top row and you win: the tower holds, the field fills from the floor up, flashes, and drains. Lose every column and the stack flashes and crumbles from the top down before the game starts over.

If nobody presses the button for 4 seconds, the board plays itself, and it plays like a person rather than a machine: steady on the slow, wide blocks near the floor, shakier as the block narrows and speeds up, a beat late now and then, and it reaches the top about one game in four. One real press takes the game back at once.

Parts list

5 parts, plus the jumper wires. They retail for about $17.50; on Mokxi they're free.

Wiring table

Every connection, pin by pin, read from the circuit itself. Each row joins the pin on the left to the pin on the right, by a jumper wire or a breadboard strip.

Part and pinConnects to
Arcade button pin 2ESP32-C3-DevKitM-1 pin 3
Arcade button pin 1ESP32-C3-DevKitM-1 pin GND
LED matrix, 8x8 (1) pin GNDESP32-C3-DevKitM-1 pin GND
LED matrix, 8x8 (2) pin GNDESP32-C3-DevKitM-1 pin GND
LED matrix, 8x8 (1) pin CLKESP32-C3-DevKitM-1 pin 5
LED matrix, 8x8 (2) pin CLKESP32-C3-DevKitM-1 pin 5
LED matrix, 8x8 (1) pin CSESP32-C3-DevKitM-1 pin 6
LED matrix, 8x8 (2) pin CSESP32-C3-DevKitM-1 pin 6
LED matrix, 8x8 (1) pin DINESP32-C3-DevKitM-1 pin 7
LED matrix, 8x8 (1) pin VCCESP32-C3-DevKitM-1 pin 5V
LED matrix, 8x8 (2) pin VCCESP32-C3-DevKitM-1 pin 5V
LED matrix, 8x8 (2) pin DINLED matrix, 8x8 (1) pin DOUT

The code

The firmware exactly as the editor opens it. Change a line there and press Run: it compiles in the browser.

sketch.ino · ESP32-C3-DevKitM-1
// 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 halfway up and to one near the top, and every
// level is a little quicker than the one under it. Whatever misses does not
// just vanish: the cut-off columns fall, a row at a time and faster as they
// go, until they land on a ledge of the stack or the floor, blink there, and
// are gone. Miss the row below completely and the whole block falls, the
// stack flashes and crumbles from the top down, and the game starts again.
// Land a block on the top row and that is the win: the finished tower holds a
// moment, the field fills from the floor up, flashes, drains from the top
// down, and a new game starts.
//
// At power-on the game is already playing (owner: "straight to playing"):
// no title, and the board plays itself from the first frame until somebody
// presses the button.
//
// Nothing else ever lights a pixel the stack and the moving block do not own.
// An earlier version blinked a full-width "minor prize" line across the stack
// when it reached the top row of the bottom module, which on the glass read as
// a bar snapping out to the whole width and back: a glitch, not a reward.
//
// After a person's press, four seconds with nothing pressed and the board
// plays itself again, and it plays like a person rather than a machine. It
// never picks a column: it watches the block come and times its press, and its timing wobbles the way
// a hand does. On the slow blocks near the floor a wobble still lands on the
// right column; as the block speeds up the same wobble starts to land a
// column early or late, trimming the stack, and on the single-column rows at
// the top one slip ends the game. About one game in three reaches the top.
// 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;
/** Halfway up, the top row of the bottom module: the block narrows to two. */
const uint8_t NARROW_LEVEL = 7;
/** The top of the cabinet: land a block here and the game is won. */
const uint8_t TOP_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;

/**
 * A cut-off piece falls like a thing with weight: its first row takes
 * FALL_FIRST milliseconds, every row after that FALL_GAIN less, and no row
 * less than FALL_LEAST. Nothing under fifty, so a screen drawn thirty times a
 * second shows every row on the way down.
 */
const uint32_t FALL_FIRST = 95;
const uint32_t FALL_LEAST = 50;
const uint32_t FALL_GAIN = 9;
/** How long a landed piece blinks before it goes out. */
const uint32_t LANDED_BLINK = 80;

/** 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_;

/* The attract player's hand. */
/** This game's lean, in ms: some games it tends to press a little early, some late. */
static int16_t lean_;
/** When its finger lands; 0 while it is still watching the block come. */
static uint32_t press_at_;

/* ---- 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 button ------------------------------------------------------ */

/**
 * Read the button once. The button is active low, and only the edge counts:
 * holding it down is one press, not a press every time it is looked at.
 * Returns true on the press itself.
 */
static bool button_pressed()
{
  bool down = digitalRead(BUTTON_PIN) == LOW;
  if (down && !held_) {
    held_ = true;
    last_press_ = millis();
    return true;
  }
  if (!down) {
    held_ = false;
  }
  return false;
}

/**
 * Sleep through part of an animation without going deaf: the button is read
 * every two milliseconds the whole way. A press here is a person asking for
 * the game, so attract mode ends and the next game is theirs; the press
 * drops nothing, it is spent on waking the board up. Returns true if the
 * button went down.
 */
static bool wait(uint32_t ms)
{
  uint32_t from = millis();
  bool pressed = false;
  while (millis() - from < ms) {
    if (button_pressed()) {
      attract_ = false;
      pressed = true;
    }
    delay(2);
  }
  return pressed;
}

/* ---- 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;
}

/** A whole row of the field, dark. */
static void clear_row(uint8_t level)
{
  frame_[LEVELS - 1 - level] = 0;
}

/** 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 -------------------------------------------------------- */

/**
 * A random number around 0, bell-shaped like a person's timing: three even
 * draws between -size and size, added up. Their spread is `size` either way,
 * and they never reach past three times that.
 */
static int32_t wobble(int32_t size)
{
  int32_t sum = 0;
  for (uint8_t i = 0; i < 3; i++) {
    sum += random(-size, size + 1);
  }
  return sum;
}

/** 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 >= TOP_LEVEL - 2) return 1;
  if (level >= NARROW_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) / TOP_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]);
}

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();
  press_at_ = 0;
}

/**
 * Any bytes waiting on the serial line become the seed. The simulator is
 * deterministic, so on its own every visit to the home page would play the
 * same games; the page sends four random bytes a second after it starts the
 * board (after Serial.begin, which empties the receive buffer), and this mixes
 * them in before the next game. Nothing waiting, nothing changes.
 */
static void seed_from_serial()
{
  uint32_t seed = 0;
  bool any = false;
  while (Serial.available() > 0) {
    seed = seed * 31u + (uint8_t)Serial.read();
    any = true;
  }
  if (any) randomSeed(seed ^ micros());
}

/** A fresh floor, and the first block on its way at once. */
static void new_game()
{
  seed_from_serial();
  for (uint8_t level = 0; level < LEVELS; level++) {
    stack_[level] = 0;
  }
  width_ = width_at(0);
  lean_ = (int16_t)wobble(8);
  blank();
  start_level(0);
}

/**
 * Trimmed to nothing. What was built flashes three times, then crumbles: its
 * rows go out from the top down, a quick wipe to a dark floor, and after a
 * pause the game starts again.
 */
static void game_over()
{
  for (uint8_t flash = 0; flash < 3; flash++) {
    blank();
    wait(150);
    paint(false);
    show();
    wait(150);
  }
  for (int8_t level = TOP_LEVEL; level >= 0; level--) {
    if (stack_[level] == 0) continue;
    stack_[level] = 0;
    paint(false);
    show();
    wait(45);
  }
  wait(400);
  new_game();
}

/**
 * The top of the cabinet. The finished tower stays up long enough to see the
 * last block land; then the field fills around it from the floor up, the whole
 * of it flashes three times, drains from the top down to a dark floor, and
 * after a pause a new game starts. Nothing like the game-over blink, which is
 * only the stack going on and off.
 */
static void win()
{
  wait(700);
  for (uint8_t level = 0; level < LEVELS; level++) {
    plot_row(level);
    show();
    wait(40);
  }
  for (uint8_t flash = 0; flash < 3; flash++) {
    blank();
    wait(160);
    for (uint8_t level = 0; level < LEVELS; level++) {
      plot_row(level);
    }
    show();
    wait(220);
  }
  for (int8_t level = TOP_LEVEL; level >= 0; level--) {
    clear_row((uint8_t)level);
    show();
    wait(30);
  }
  wait(500);
  new_game();
}

/** Can a loose pixel in this column sit at this level, or is it resting? */
static bool can_fall(uint8_t column, int8_t level)
{
  return level > 0 && !(stack_[level - 1] & (1u << column));
}

/**
 * The columns of `loose` that missed, falling from `from`: every frame each one
 * that still has empty space under it drops a row, over the stack as it now
 * stands, until all of them rest on a ledge or the floor. The rows come
 * quicker as the piece gathers speed. Then they blink twice where they
 * stopped and go out, so the eye sees where the miss went.
 */
static void fall(uint8_t loose, uint8_t from)
{
  int8_t height[COLUMNS];
  for (uint8_t column = 0; column < COLUMNS; column++) {
    height[column] = (int8_t)from;
  }
  uint32_t step = FALL_FIRST;
  bool moving = true;
  while (moving) {
    paint(false);
    moving = false;
    for (uint8_t column = 0; column < COLUMNS; column++) {
      if (!(loose & (1u << column))) continue;
      plot(column, (uint8_t)height[column]);
      if (can_fall(column, height[column])) moving = true;
    }
    show();
    if (!moving) break;
    wait(step);
    for (uint8_t column = 0; column < COLUMNS; column++) {
      if ((loose & (1u << column)) && can_fall(column, height[column])) {
        height[column]--;
      }
    }
    step = step > FALL_LEAST + FALL_GAIN ? step - FALL_GAIN : FALL_LEAST;
  }
  for (uint8_t blink = 0; blink < 2; blink++) {
    wait(LANDED_BLINK);
    paint(false);
    show();
    wait(LANDED_BLINK);
    for (uint8_t column = 0; column < COLUMNS; column++) {
      if (loose & (1u << column)) plot(column, (uint8_t)height[column]);
    }
    show();
  }
  wait(LANDED_BLINK);
  paint(false);
  show();
}

/**
 * Drop the block: keep only the columns standing on the row below. The ones
 * hanging over fall away, and if that is all of them the game is over. The
 * floor catches anything.
 */
static void drop()
{
  uint8_t block = block_mask();
  uint8_t landed = block;
  if (level_ > 0) {
    landed = (uint8_t)(landed & stack_[level_ - 1]);
  }
  if (landed == 0) {
    fall(block, level_);
    game_over();
    return;
  }
  stack_[level_] = landed;
  if (landed != block) {
    fall((uint8_t)(block & ~landed), level_);
  }
  width_ = count_bits(landed);
  pos_ = (int8_t)lowest_bit(landed);
  paint(false);
  show();

  if (level_ == TOP_LEVEL) {
    win();
    return;
  }
  start_level((uint8_t)(level_ + 1));
}

/* ---- the attract player ---------------------------------------------- */

/**
 * The block has just moved. When the next step will put it on the column
 * that keeps the whole block (the left edge of the row below), the player
 * times its press for the middle of the block's stay there, the way a person
 * presses on the beat of a rhythm they have been watching. Nobody hits the
 * middle exactly, so the press lands off it by a timing error:
 *
 *   - the spread, a normal-ish wobble with a typical size of 26 ms on the
 *     first press of a game, settling to 20 ms as the player warms up over the
 *     first five rows, then growing 2 ms a row past halfway as the speed and
 *     the stakes rise (a reaction of 220 ms give or take 40, early on);
 *   - the game's lean, a few ms early or late for the whole game;
 *   - now and then (one press in twelve) a plain slip, early or late by
 *     between a half and most of a step.
 *
 * An error under half a step still lands on the right column, so the wide,
 * slow rows at the floor stack clean (a step there is 210 ms) and the narrow,
 * fast ones at the top (70 ms) slip a column now and then. The error is
 * capped just under a step and a half either way, so a press is at worst one
 * column early or late: a near miss that trims the stack, never a wild one.
 */
static void attract_watch(uint32_t now)
{
  if (press_at_ != 0 || (int8_t)(pos_ + dir_) != perfect_stop()) return;
  int32_t step = (int32_t)step_ms_;
  int32_t level = level_;
  int32_t spread = 26 - level * 6 / 5;
  if (spread < 20) spread = 20;
  if (level > 6) spread += 2 * (level - 6);
  int32_t error = lean_ + wobble(spread);
  if (random(12) == 0) {
    int32_t slip = random(step * 2 / 5, step * 4 / 5);
    error += random(2) == 0 ? -slip : slip;
  }
  int32_t most = step * 7 / 5;
  if (error < -most) error = -most;
  if (error > most) error = most;
  // One step to arrive, half a step to the middle of its stay, and the error.
  press_at_ = now + (uint32_t)(step + step / 2 + error);
}

/** The finger lands, if its moment has come. */
static void attract_press(uint32_t now)
{
  if (press_at_ == 0 || (int32_t)(now - press_at_) < 0) return;
  press_at_ = 0;
  // Said on the serial line, so the simulator can show the button going down:
  // the pin itself never moves, since nobody's finger is on the cap.
  Serial.print("[press ");
  Serial.print(BUTTON_PIN);
  Serial.println("]");
  drop();
}

/* ---- 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_ = true;
  held_ = false;
  new_game();
}

void loop()
{
  uint32_t now = millis();

  // A real press drops the block and takes the game back from the attract
  // player, whatever it was about to do.
  if (button_pressed()) {
    attract_ = false;
    press_at_ = 0;
    // A person's press lands at a moment no program can predict, so it makes
    // a good seed: the attract games after it differ from the ones before.
    randomSeed(micros());
    drop();
    return;
  }
  if (!attract_ && now - last_press_ >= ATTRACT_AFTER_MS) {
    attract_ = true;
  }
  if (attract_) {
    attract_press(now);
  }

  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_) {
    attract_watch(now);
  }
}

Code walkthrough

The key functions of the sketch, and what each one does.

write_chain sketch.ino

Sends one register write to both chips at once. With CS low it shifts out the bottom module's packet first, then the top module's, then raises CS to latch. The bottom packet goes first because it has to travel through the top chip and out its DOUT pin.

/**
 * 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);
}

drop sketch.ino

Settles the moving block. It keeps only the columns that sit on top of the row below, using a bitwise AND with stack_. If none are left, the whole block falls and the game ends. If some hang over, fall animates them. The block then gets as narrow as what landed, and the next level starts.

/**
 * Drop the block: keep only the columns standing on the row below. The ones
 * hanging over fall away, and if that is all of them the game is over. The
 * floor catches anything.
 */
static void drop()
{
  uint8_t block = block_mask();
  uint8_t landed = block;
  if (level_ > 0) {
    landed = (uint8_t)(landed & stack_[level_ - 1]);
  }
  if (landed == 0) {
    fall(block, level_);
    game_over();
    return;
  }
  stack_[level_] = landed;
  if (landed != block) {
    fall((uint8_t)(block & ~landed), level_);
  }
  width_ = count_bits(landed);
  pos_ = (int8_t)lowest_bit(landed);
  paint(false);
  show();

  if (level_ == TOP_LEVEL) {
    win();
  // ... 4 more lines in the editor

fall sketch.ino

Animates the cut-off columns. Each frame, every loose column with empty space under it drops one row, and the frames come quicker as the piece gathers speed. When they all rest on a ledge or the floor, they blink twice where they stopped and disappear.

/**
 * The columns of `loose` that missed, falling from `from`: every frame each one
 * that still has empty space under it drops a row, over the stack as it now
 * stands, until all of them rest on a ledge or the floor. The rows come
 * quicker as the piece gathers speed. Then they blink twice where they
 * stopped and go out, so the eye sees where the miss went.
 */
static void fall(uint8_t loose, uint8_t from)
{
  int8_t height[COLUMNS];
  for (uint8_t column = 0; column < COLUMNS; column++) {
    height[column] = (int8_t)from;
  }
  uint32_t step = FALL_FIRST;
  bool moving = true;
  while (moving) {
    paint(false);
    moving = false;
    for (uint8_t column = 0; column < COLUMNS; column++) {
      if (!(loose & (1u << column))) continue;
      plot(column, (uint8_t)height[column]);
      if (can_fall(column, height[column])) moving = true;
    }
    show();
    if (!moving) break;
    wait(step);
    for (uint8_t column = 0; column < COLUMNS; column++) {
      if ((loose & (1u << column)) && can_fall(column, height[column])) {
  // ... 19 more lines in the editor

attract_watch sketch.ino

Plays the attract mode like a person. When the next step puts the block on the column that keeps it whole, it times a press for the middle of the block's stay there, off by a human error: a wobble of about 20 to 26 ms that grows near the top, a small lean for the whole game, and one press in twelve a plain slip. The error is capped so a miss is never more than one column. attract_press presses when that moment comes.

/**
 * The block has just moved. When the next step will put it on the column
 * that keeps the whole block (the left edge of the row below), the player
 * times its press for the middle of the block's stay there, the way a person
 * presses on the beat of a rhythm they have been watching. Nobody hits the
 * middle exactly, so the press lands off it by a timing error:
 *
 *   - the spread, a normal-ish wobble with a typical size of 26 ms on the
 *     first press of a game, settling to 20 ms as the player warms up over the
 *     first five rows, then growing 2 ms a row past halfway as the speed and
 *     the stakes rise (a reaction of 220 ms give or take 40, early on);
 *   - the game's lean, a few ms early or late for the whole game;
 *   - now and then (one press in twelve) a plain slip, early or late by
 *     between a half and most of a step.
 *
 * An error under half a step still lands on the right column, so the wide,
 * slow rows at the floor stack clean (a step there is 210 ms) and the narrow,
 * fast ones at the top (70 ms) slip a column now and then. The error is
 * capped just under a step and a half either way, so a press is at worst one
 * column early or late: a near miss that trims the stack, never a wild one.
 */
static void attract_watch(uint32_t now)
{
  if (press_at_ != 0 || (int8_t)(pos_ + dir_) != perfect_stop()) return;
  int32_t step = (int32_t)step_ms_;
  int32_t level = level_;
  int32_t spread = 26 - level * 6 / 5;
  if (spread < 20) spread = 20;
  // ... 12 more lines in the editor

loop sketch.ino

Watches the button and acts only on the press, not while it is held. Otherwise it moves the block one column every step_ms_ milliseconds, bouncing off the edges, and redraws. In attract mode it lets the attract player decide when to drop.

void loop()
{
  uint32_t now = millis();

  // A real press drops the block and takes the game back from the attract
  // player, whatever it was about to do.
  if (button_pressed()) {
    attract_ = false;
    press_at_ = 0;
    // A person's press lands at a moment no program can predict, so it makes
    // a good seed: the attract games after it differ from the ones before.
    randomSeed(micros());
    drop();
    return;
  }
  if (!attract_ && now - last_press_ >= ATTRACT_AFTER_MS) {
    attract_ = true;
  }
  if (attract_) {
    attract_press(now);
  }

  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;
  }
  // ... 17 more lines in the editor

Troubleshooting

The usual mistakes with this circuit, and the fix for each.

  • Only the top matrix lights up This project
    Check the cascade: the top module's DOUT must go to the bottom module's DIN. CLK and CS go to both modules, but the Arduino's DIN pin goes only to the top one.
  • The picture is upside down or the two halves are swapped This project
    The top module must be the one wired to pin 7. The sketch sends the bottom module's rows first.
  • The button drops nothing This project
    Wire it from pin 3 to GND. The sketch uses INPUT_PULLUP, so a press reads LOW.
  • The button reads pressed all the time, or never changes, when wired to a pin. Arcade button
    Use the COM and NO terminals of the microswitch. Wire COM to ground, NO to the pin, and use INPUT_PULLUP. NC is the opposite and reads pressed when released.
  • One press counts as several presses in your code. Arcade button
    Microswitch contacts bounce for a few milliseconds. Ignore changes for 10 to 20 ms after the first one, or react only to the edge and wait before checking again.
  • The matrix is blank after upload. LED matrix
    On power up the MAX7219 is in shutdown mode with intensity 0. Wake it, set intensity, set scan limit to all 8 digits, and clear the display.
  • Dim, flickering, or wrong rows. LED matrix
    Set the ISET resistor right for your LEDs (about 10 kohm for 40 mA). Power from 5 V with a 0.1 uF capacitor, and chain DOUT to the next DIN.
  • Upload fails or the board is not found by the computer. ESP32-C3-DevKitM-1
    Pick the ESP32-C3 board in your tools and the right port. If it still fails, hold BOOT, tap RESET, then release BOOT to enter download mode and try again.

Change it and keep it

Open it in the editor, change the circuit or the code, and keep your version in a free account.