Snake on two matrices, on an Arduino Uno
An 8x16 play field on two MAX7219 modules, steered with a thumb stick: eat the apple, grow, and do not hit a wall or yourself. The whole build, an Arduino Uno 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 $37. On Mokxi they're free.
Step by step, with the wiring and the common mistakes: Arduino Snake Game on an 8x8 LED Matrix (MAX7219)
What you'll learn
- Driving two MAX7219 matrices with a chip select each
- Mapping a cell number to a row, column and module
- Reading a thumb stick as a direction instead of a position
- Storing a snake's body in a ring buffer of bytes
- Speeding up a game by shrinking the step time
- Difficulty: Advanced
- Time: About 30 minutes
- Board: Arduino Uno R3
- Parts: 5
How it works
Two MAX7219 modules stack into an 8 by 16 play field. They share data on pin 11 and clock on pin 13, and each has its own chip select, pin 10 for the top and pin 9 for the bottom. The sketch keeps the snake as a list of cell numbers and moves it one step at a time.
Push the thumb stick on A0 and A1 and the snake turns that way. A turn straight back into its own neck is ignored. The snake waits until your first push or press. Each step writes the new head and wipes the old tail, so only two pixels change.
Eating the apple grows the snake and speeds it up, from 240 ms a step down to 90 ms. Hitting a wall or yourself ends the game. Press the stick to start again.
Parts list
5 parts, plus the jumper wires. They retail for about $37; on Mokxi they're free.
- 1 × Arduino Uno R3In Starter Kit, Claw Machine Kit, Vending Machine Kit.
- 1 × Half breadboard
- 2 × LED matrix, 8x8
- 1 × Thumb stickIn Claw Machine Kit, Spaceship Panel Kit.
Kits that can build it
This starter kit has every part in the circuit. Each kit's page lists what else it can build.
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 pin | Connects to |
|---|---|
| LED matrix, 8x8 (1) pin GND | Arduino Uno R3 pin GND |
| LED matrix, 8x8 (2) pin GND | Arduino Uno R3 pin GND |
| Thumb stick pin GND | Arduino Uno R3 pin GND |
| LED matrix, 8x8 (1) pin CLK | Arduino Uno R3 pin 13 |
| LED matrix, 8x8 (2) pin CLK | Arduino Uno R3 pin 13 |
| LED matrix, 8x8 (1) pin DIN | Arduino Uno R3 pin 11 |
| LED matrix, 8x8 (2) pin DIN | Arduino Uno R3 pin 11 |
| LED matrix, 8x8 (1) pin CS | Arduino Uno R3 pin 10 |
| LED matrix, 8x8 (2) pin CS | Arduino Uno R3 pin 9 |
| Thumb stick pin SW | Arduino Uno R3 pin 2 |
| LED matrix, 8x8 (1) pin VCC | Arduino Uno R3 pin 5V |
| LED matrix, 8x8 (2) pin VCC | Arduino Uno R3 pin 5V |
| Thumb stick pin VCC | Arduino Uno R3 pin 5V |
| Thumb stick pin VRX | Arduino Uno R3 pin A0 |
| Thumb stick pin VRY | Arduino Uno R3 pin A1 |
The code
The firmware exactly as the editor opens it. Change a line there and press Run: it compiles in the browser.
// Snake on two matrices: an 8x16 play field on a pair of MAX7219 modules,
// steered with a thumb stick.
//
// Wiring, board to modules:
// DIN -> pin 11 and CLK -> pin 13, shared by both modules
// CS -> pin 10 on the top module, pin 9 on the bottom one
// VCC -> 5 V, GND -> GND
// stick VRX -> A0, VRY -> A1, SW -> pin 2, VCC -> 5 V, GND -> GND
//
// The same cabinet the `stacker` example builds on the ESP32-C3: two 8x8
// modules standing one above the other, eight columns wide and sixteen rows
// tall. They share data and clock and have a chip select each, so a packet
// goes to exactly the module whose CS is low.
//
// `stacker` cascades its pair instead, DOUT into the next DIN, which saves a
// pin. This one spends it: a cascade sends both modules' packets between one
// pair of CS edges, and a snake step touches two pixels that are usually in
// the same half, so a select each means a step writes one module's eight bytes
// and leaves the other alone.
//
// # Why this is an Uno
//
// The stick came to this board when the ESP32-C3 model had no ADC to read a
// potentiometer with; it has one now (docs/esp32c3.md section 3.5), so the
// game stays here by choice. Everything else about this circuit is the
// stacker's, pin for pin in spirit; only the board and the input differ, and
// a board apiece is what makes the two worth comparing.
//
// # Steering
//
// A stick is an absolute position and a snake has a heading, so the stick is
// read as a direction rather than a place: push it far enough off centre and
// the snake turns that way, let it spring back and the snake carries on. A
// turn into the snake's own neck is refused, which is what stops a flick of the
// wrist from ending the game.
//
// # The body
//
// A ring buffer of cell numbers, one byte each, so a snake as long as the whole
// field costs 128 bytes of the Uno's 2048. A step writes the new head and, when
// the snake has not just eaten, rubs out the old tail: two pixels a step, and
// the other 126 are left exactly as they are.
#include "mokxi_joystick.h"
#include "mokxi_max7219.h"
const uint8_t DIN_PIN = 11;
const uint8_t CLK_PIN = 13;
const uint8_t CS_TOP_PIN = 10;
const uint8_t CS_BOTTOM_PIN = 9;
const uint8_t VRX_PIN = A0;
const uint8_t VRY_PIN = A1;
const uint8_t SW_PIN = 2;
Max7219 top(DIN_PIN, CS_TOP_PIN, CLK_PIN, 1);
Max7219 bottom(DIN_PIN, CS_BOTTOM_PIN, CLK_PIN, 1);
Joystick stick(VRX_PIN, VRY_PIN, SW_PIN);
const uint8_t COLUMNS = 8;
const uint8_t ROWS = 16;
const uint8_t CELLS = COLUMNS * ROWS;
/**
* How far off centre the stick has to go before it counts as a push, on top of
* the header's own dead zone.
*/
const int16_t THROW = 160;
/** Milliseconds a step takes, at the start and at its quickest. */
const uint32_t SLOW_STEP = 240;
const uint32_t FAST_STEP = 90;
/** The body, oldest segment first, as cell numbers (`row * COLUMNS + col`). */
uint8_t body[CELLS];
uint8_t head = 0;
uint8_t length = 3;
int8_t dx = 0;
int8_t dy = 1;
uint8_t apple = 0;
uint32_t stepMs = SLOW_STEP;
uint32_t lastStep = 0;
bool dead = false;
/**
* True between a new game being laid out and the player touching anything.
*
* Without it the snake sets off the moment the board powers up, and the field
* is sixteen rows: thirteen steps of runway, which at a step every 240 ms is
* three seconds from Run to a wall. Nobody gets a hand to the stick in three
* seconds. So the snake stands still, apple and all, until the first push or
* press, which is also what a cabinet does.
*/
bool waiting = true;
uint16_t lfsr = 0xd00du;
static uint8_t nextRandom() {
// Taps at 16, 14, 13 and 11: the maximal-length sixteen-bit LFSR.
uint16_t bit = (uint16_t)(((lfsr >> 0) ^ (lfsr >> 2) ^ (lfsr >> 3) ^ (lfsr >> 5)) & 1u);
lfsr = (uint16_t)((lfsr >> 1) | (bit << 15));
return (uint8_t)(lfsr % CELLS);
}
/** Light or clear one cell of the field, whichever module it falls on. */
static void plot(uint8_t cell, bool on) {
uint8_t row = (uint8_t)(cell / COLUMNS);
uint8_t col = (uint8_t)(cell % COLUMNS);
if (row < 8) {
top.setPixel((int16_t)col, (int16_t)row, on);
} else {
bottom.setPixel((int16_t)col, (int16_t)(row - 8), on);
}
}
static void show() {
top.show();
bottom.show();
}
static void blank() {
top.clear();
bottom.clear();
show();
}
/** Is this cell part of the snake? */
static bool occupied(uint8_t cell) {
for (uint8_t i = 0; i < length; i++) {
uint8_t at = (uint8_t)((head + CELLS - i) % CELLS);
if (body[at] == cell) {
return true;
}
}
return false;
}
/** Drop an apple somewhere the snake is not. */
static void placeApple() {
for (uint8_t tries = 0; tries < 200; tries++) {
uint8_t cell = nextRandom();
if (!occupied(cell)) {
apple = cell;
plot(apple, true);
return;
}
}
}
static void newGame() {
blank();
length = 3;
head = 0;
dx = 0;
dy = 1;
stepMs = SLOW_STEP;
dead = false;
// Three segments down the middle, heading down the field.
for (uint8_t i = 0; i < length; i++) {
body[i] = (uint8_t)(i * COLUMNS + 3);
plot(body[i], true);
}
head = (uint8_t)(length - 1);
placeApple();
show();
waiting = true;
lastStep = millis();
}
/**
* The stick as a heading, or no change when it is near the middle.
*
* The offsets come from mokxi_joystick.h rather than from a bare analogRead,
* for two reasons an ATmega328P makes unavoidable: the first conversion after
* the mux moves to another channel is the one to throw away, so reading two
* axes in a row without that gives the second one the first one's answer; and
* a stick does not spring back to exactly 512, so `begin()` samples the rest
* position and everything here is measured from that. Without either, the
* snake turns into a wall a second after the game starts, which is what this
* sketch did until it did this.
*/
static void steer() {
int16_t x = stick.dx();
int16_t y = stick.dy();
int8_t wantX = 0;
int8_t wantY = 0;
// Whichever axis is pushed further wins, so a diagonal is not ambiguous.
int16_t ax = (int16_t)(x < 0 ? -x : x);
int16_t ay = (int16_t)(y < 0 ? -y : y);
if (ax > ay) {
if (ax > THROW) {
wantX = (int8_t)(x > 0 ? 1 : -1);
}
} else if (ay > THROW) {
wantY = (int8_t)(y > 0 ? 1 : -1);
}
if (wantX == 0 && wantY == 0) {
return;
}
// The first push is also what starts the game.
waiting = false;
// A turn into its own neck is refused: the snake cannot reverse.
if (wantX != 0 && dx != 0) {
return;
}
if (wantY != 0 && dy != 0) {
return;
}
dx = wantX;
dy = wantY;
}
void setup() {
Serial.begin(115200);
Serial.println("Mokxi Uno: snake on two MAX7219 matrices, steered with a thumb stick");
stick.begin();
top.begin(6);
bottom.begin(6);
// The seed is how long the player waits before the first push, which is the
// only entropy a board with no clock battery has.
newGame();
}
void loop() {
steer();
if (dead) {
if (stick.pressed()) {
lfsr ^= (uint16_t)millis();
if (lfsr == 0u) {
lfsr = 0xd00du;
}
newGame();
while (stick.pressed()) {
delay(8);
}
}
delay(16);
return;
}
if (waiting) {
// Standing still. A press starts it too, for a player who would rather
// keep the heading it was laid out with.
if (stick.pressed()) {
waiting = false;
lastStep = millis();
while (stick.pressed()) {
delay(8);
}
}
delay(8);
return;
}
if ((uint32_t)(millis() - lastStep) < stepMs) {
// Keep reading the stick between steps: a flick that begins and ends
// inside one step would otherwise never be seen.
delay(4);
return;
}
lastStep = millis();
uint8_t from = body[head];
int8_t col = (int8_t)(from % COLUMNS);
int8_t row = (int8_t)(from / COLUMNS);
col = (int8_t)(col + dx);
row = (int8_t)(row + dy);
// The walls are walls, not a wrap: this is the version you can lose.
if (col < 0 || col >= (int8_t)COLUMNS || row < 0 || row >= (int8_t)ROWS) {
dead = true;
}
uint8_t cell = 0;
if (!dead) {
cell = (uint8_t)(row * COLUMNS + col);
if (occupied(cell)) {
dead = true;
}
}
if (dead) {
Serial.println("bitten");
// Three flashes of the whole snake, then wait for the stick to be pressed.
for (uint8_t f = 0; f < 3; f++) {
for (uint8_t i = 0; i < length; i++) {
plot(body[(uint8_t)((head + CELLS - i) % CELLS)], false);
}
show();
delay(160);
for (uint8_t i = 0; i < length; i++) {
plot(body[(uint8_t)((head + CELLS - i) % CELLS)], true);
}
show();
delay(160);
}
return;
}
bool ate = cell == apple;
head = (uint8_t)((head + 1) % CELLS);
body[head] = cell;
plot(cell, true);
if (ate) {
if (length < CELLS - 1) {
length++;
}
// Every apple makes it a little quicker, down to a floor.
if (stepMs > FAST_STEP) {
stepMs -= 8;
}
placeApple();
Serial.println("apple");
} else {
// Rub out the tail: the segment that just fell off the end.
uint8_t tail = (uint8_t)((head + CELLS - length) % CELLS);
plot(body[tail], false);
}
show();
}
Code walkthrough
The key functions of the sketch, and what each one does.
plot sketch.ino
Turns one cell of the field on or off. It splits the cell number into a row and a column. Rows 0 to 7 go to the top module, and rows 8 to 15 go to the bottom module with 8 taken off the row.
/** Light or clear one cell of the field, whichever module it falls on. */
static void plot(uint8_t cell, bool on) {
uint8_t row = (uint8_t)(cell / COLUMNS);
uint8_t col = (uint8_t)(cell % COLUMNS);
if (row < 8) {
top.setPixel((int16_t)col, (int16_t)row, on);
} else {
bottom.setPixel((int16_t)col, (int16_t)(row - 8), on);
}
}steer sketch.ino
Reads how far the stick is from center on both axes. Whichever is pushed further wins, and it only counts past THROW. The first push also starts the game. A turn along the axis the snake is already moving on is refused, so it cannot reverse.
/**
* The stick as a heading, or no change when it is near the middle.
*
* The offsets come from mokxi_joystick.h rather than from a bare analogRead,
* for two reasons an ATmega328P makes unavoidable: the first conversion after
* the mux moves to another channel is the one to throw away, so reading two
* axes in a row without that gives the second one the first one's answer; and
* a stick does not spring back to exactly 512, so `begin()` samples the rest
* position and everything here is measured from that. Without either, the
* snake turns into a wall a second after the game starts, which is what this
* sketch did until it did this.
*/
static void steer() {
int16_t x = stick.dx();
int16_t y = stick.dy();
int8_t wantX = 0;
int8_t wantY = 0;
// Whichever axis is pushed further wins, so a diagonal is not ambiguous.
int16_t ax = (int16_t)(x < 0 ? -x : x);
int16_t ay = (int16_t)(y < 0 ? -y : y);
if (ax > ay) {
if (ax > THROW) {
wantX = (int8_t)(x > 0 ? 1 : -1);
}
} else if (ay > THROW) {
wantY = (int8_t)(y > 0 ? 1 : -1);
}
if (wantX == 0 && wantY == 0) {
// ... 14 more lines in the editorplaceApple sketch.ino
Picks a random cell with nextRandom and tries again if the snake is on it, up to 200 times. When it finds a free cell, it remembers it and lights it.
/** Drop an apple somewhere the snake is not. */
static void placeApple() {
for (uint8_t tries = 0; tries < 200; tries++) {
uint8_t cell = nextRandom();
if (!occupied(cell)) {
apple = cell;
plot(apple, true);
return;
}
}
}loop sketch.ino
Reads the stick, then handles the dead and waiting states. Once a step is due, it moves the head by dx and dy and checks for a wall or the snake's body. If it ate the apple it grows and speeds up. Otherwise it clears the tail.
void loop() {
steer();
if (dead) {
if (stick.pressed()) {
lfsr ^= (uint16_t)millis();
if (lfsr == 0u) {
lfsr = 0xd00du;
}
newGame();
while (stick.pressed()) {
delay(8);
}
}
delay(16);
return;
}
if (waiting) {
// Standing still. A press starts it too, for a player who would rather
// keep the heading it was laid out with.
if (stick.pressed()) {
waiting = false;
lastStep = millis();
while (stick.pressed()) {
delay(8);
}
}
// ... 71 more lines in the editorTroubleshooting
The usual mistakes with this circuit, and the fix for each.
- Only one half of the field lights up This project
Each module needs its own CS pin: pin 10 for the top and pin 9 for the bottom. Check that both share DIN and CLK. - The snake does not turn when you push the stick This project
Check VRX on A0 and VRY on A1. The push must go pastTHROWto count, so push farther or lower that number. - The game stays frozen after it starts This project
It waits for your first push or press. Move the stick or press its button on pin 2. - 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. - The axis reads about half scale at rest instead of exactly zero, and the values drift a little, so the cursor or motor creeps. Thumb stick
Center is about half of the ADC range (about 512 on a 10 bit Arduino). Read it at startup and subtract that, then add a dead zone of a few counts. - The push button reads random values or always looks pressed. The switch pin is a plain contact to ground and has no pull-up on the stick. Thumb stick
Set the switch pin to INPUT_PULLUP and treat LOW as pressed. Debounce it in code. Power the stick from the same voltage as the ADC reference. - Upload fails or nothing runs after wiring pins 0 and 1 to a module. Arduino Uno R3
Pins 0 and 1 are the USB serial port. Move the module to other pins, or unplug it while uploading. Also check the right board is selected.
Change it and keep it
Open it in the editor, change the circuit or the code, and keep your version in a free account.