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.
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.
// 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();
}
Parts list
5 parts, plus the jumper wires. Every one is in the editor's parts bin.
- 1 × Arduino Uno R3
- 1 × Half breadboard
- 2 × LED matrix, 8x8
- 1 × Thumb stick
How it is wired
9 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.
- Ground: Arduino Uno R3 pin GND; LED matrix, 8x8 (1) pin GND; LED matrix, 8x8 (2) pin GND; Thumb stick pin GND
- Arduino Uno R3 pin 13; LED matrix, 8x8 (1) pin CLK; LED matrix, 8x8 (2) pin CLK
- Arduino Uno R3 pin 11; LED matrix, 8x8 (1) pin DIN; LED matrix, 8x8 (2) pin DIN
- Arduino Uno R3 pin 10; LED matrix, 8x8 (1) pin CS
- Arduino Uno R3 pin 9; LED matrix, 8x8 (2) pin CS
- Arduino Uno R3 pin 2; Thumb stick pin SW
- Arduino Uno R3 pin 5V; LED matrix, 8x8 (1) pin VCC; LED matrix, 8x8 (2) pin VCC; Thumb stick pin VCC
- Arduino Uno R3 pin A0; Thumb stick pin VRX
- Arduino Uno R3 pin A1; Thumb stick pin VRY
Change it and keep it
Open it in the editor, change the circuit or the code, and keep your version in a free account.