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.

AdvancedRuns in your browser. Free, and no account needed.

Stacker · ESP32-C3-DevKitM-1live0.000 s 0.00x
Hold the button
The circuit itself, running here on the simulator. Press what can be pressed; click anything else to open it in the editor.

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 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.

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.