Snake on an Arduino Uno
Snake on a 240x320 TFT with a thumb stick: eat the apple, grow, and repaint only the squares that moved. 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 $43. On Mokxi they're free.
What you'll learn
- Drawing a game board as a grid of cells
- Repainting only the squares that changed
- Storing a snake as a ring buffer of cell numbers
- Reading a thumb stick to steer
- Stopping a snake from reversing into itself
- Difficulty: Advanced
- Time: About 30 minutes
- Board: Arduino Uno R3
- Parts: 5
How it works
The Uno draws on a 240x320 ILI9341 screen using pins 13 (SCK), 11 (MOSI), 10 (CS) and 9 (DC). A thumb stick on A0 and A1 steers, its button on pin 2 starts a new game, and a piezo on pin 6 beeps.
The board is a grid of 16 by 20 squares, 12 pixels each. The snake starts 3 squares long and moves one square every 150 ms. Eat the red apple to grow by one and score a point.
Hitting a wall or yourself ends the game. The border turns red and the score goes to the serial monitor. Press the stick to start again.
Parts list
5 parts, plus the jumper wires. They retail for about $43; on Mokxi they're free.
- 1 × Arduino Uno R3In Starter Kit, Claw Machine Kit, Vending Machine Kit.
- 1 × Full-size breadboard
- 1 × TFT display, 240x320In Handheld Console Kit.
- 1 × Thumb stickIn Claw Machine Kit, Spaceship Panel Kit.
- 1 × Piezo speakerIn Starter Kit, Claw Machine Kit, Vending Machine Kit.
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 |
|---|---|
| TFT display, 240x320 pin SCK | Arduino Uno R3 pin 13 |
| TFT display, 240x320 pin MOSI | Arduino Uno R3 pin 11 |
| TFT display, 240x320 pin CS | Arduino Uno R3 pin 10 |
| TFT display, 240x320 pin DC | Arduino Uno R3 pin 9 |
| Piezo speaker pin 1 | Arduino Uno R3 pin 6 |
| Thumb stick pin SW | Arduino Uno R3 pin 2 |
| TFT display, 240x320 pin VCC | Arduino Uno R3 pin 5V |
| TFT display, 240x320 pin RST | Arduino Uno R3 pin 5V |
| TFT display, 240x320 pin LED | Arduino Uno R3 pin 5V |
| Thumb stick pin VCC | Arduino Uno R3 pin 5V |
| TFT display, 240x320 pin GND | Arduino Uno R3 pin GND |
| Thumb stick pin GND | Arduino Uno R3 pin GND |
| Piezo speaker pin 2 | Arduino Uno R3 pin GND |
| 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: a 2.4 inch ILI9341, a thumb stick and a piezo, on an Arduino Uno.
//
// Wiring, panel to board:
// CS -> pin 10 SCK -> pin 13
// DC -> pin 9 MOSI -> pin 11
// RST -> 5 V LED -> 5 V VCC -> 5 V, GND -> GND
// Thumb stick:
// VRX -> A0 VRY -> A1 SW -> pin 2, VCC -> 5 V, GND -> GND
// Piezo:
// one leg -> pin 6, the other -> GND
//
// Push the stick to turn. Eat the apple, grow by one, and do not run into the
// wall or into yourself. Press the stick to start the next game.
//
// # Why the board is small squares
//
// There is no SPI peripheral on an ATmega328P in Mokxi (docs/uno.md section 3),
// so every one of the sixteen bits of a pixel is a pin change made by hand and
// the board manages some fifty thousand pixels a second. The `pong` example
// answers that by playing in a window; this one answers it by never repainting
// anything. A step of the snake is two squares (the new head and the old
// tail), which is 2 x 12 x 12 pixels, under three hundred, and the rest of
// the picture is left exactly as it was. That is a frame in six milliseconds,
// and the game runs at whatever speed the rules want rather than whatever
// speed the panel allows.
//
// The body is a ring buffer of cell numbers rather than coordinates: one byte
// a segment, so a hundred and twenty segments cost 120 of the Uno's 2048 bytes
// of SRAM. The head and the length are the only state a step changes.
#include "mokxi_ili9341.h"
#include "mokxi_joystick.h"
#include "mokxi_tone.h"
const uint8_t TFT_CS = 10;
const uint8_t TFT_DC = 9;
const uint8_t TFT_SCK = 13;
const uint8_t TFT_MOSI = 11;
const uint8_t PIEZO_PIN = 6;
SoftSpi spi(TFT_SCK, TFT_MOSI, TFT_CS);
Ili9341 tft(spi, TFT_DC);
Joystick stick(A0, A1, 2);
// The board, in cells. 16 x 20 of 12 pixels is 192 x 240 on the panel.
const uint8_t COLS = 16;
const uint8_t ROWS = 20;
const int16_t CELL = 12;
const int16_t OX = 24;
const int16_t OY = 56;
const uint16_t BG = 0x0841;
const uint16_t EDGE = ILI9341_CYAN;
const uint16_t SNAKE = ILI9341_GREEN;
const uint16_t HEAD = 0x07ff;
const uint16_t APPLE = ILI9341_RED;
const uint8_t MAX_LEN = 120;
const int STEP_MS = 150;
uint8_t body[MAX_LEN]; // cell numbers, oldest at `tail`
uint8_t head, tail, length;
int8_t dx, dy;
uint8_t apple;
int score;
bool playing;
static uint8_t cellOf(uint8_t cx, uint8_t cy) { return (uint8_t)(cy * COLS + cx); }
static uint8_t colOf(uint8_t cell) { return (uint8_t)(cell % COLS); }
static uint8_t rowOf(uint8_t cell) { return (uint8_t)(cell / COLS); }
static void paint(uint8_t cell, uint16_t colour) {
tft.fillRect((int16_t)(OX + colOf(cell) * CELL), (int16_t)(OY + rowOf(cell) * CELL), CELL, CELL,
colour);
}
static bool onSnake(uint8_t cell) {
for (uint8_t i = 0; i < length; i++) {
uint8_t at = (uint8_t)((tail + i) % MAX_LEN);
if (body[at] == cell) {
return true;
}
}
return false;
}
// A cheap xorshift rather than the runtime's random(): the apple only has to
// land somewhere unpredictable, and this example is already most of a quarter
// of the Uno's flash.
static uint16_t seed = 0xace1;
static uint8_t nextCell() {
seed ^= (uint16_t)(seed << 7);
seed ^= (uint16_t)(seed >> 9);
seed ^= (uint16_t)(seed << 8);
return (uint8_t)(seed % (COLS * ROWS));
}
static void dropApple() {
for (int tries = 0; tries < 200; tries++) {
uint8_t cell = nextCell();
if (!onSnake(cell)) {
apple = cell;
paint(apple, APPLE);
return;
}
}
}
// The border, and nothing written on the panel: the 5x7 font costs about four
// hundred bytes of flash and this sketch is already a quarter of the Uno's.
// The border is the thing that changes colour when the game ends, and the
// serial monitor carries the words.
static void border(uint16_t colour) {
tft.rect(OX - 2, OY - 2, (int16_t)(COLS * CELL + 4), (int16_t)(ROWS * CELL + 4), colour, 2);
}
static void drawFurniture() {
tft.fillScreen(ILI9341_BLACK);
border(EDGE);
}
// The score is a row of pips under the board rather than a number on the
// panel: the digit formatter is four hundred bytes of flash this sketch does
// not have, and the serial monitor prints the figure anyway.
static void drawScore() {
int16_t y = (int16_t)(OY + ROWS * CELL + 10);
tft.fillRect(OX, y, (int16_t)(COLS * CELL), 8, ILI9341_BLACK);
int pips = score > COLS * 2 ? COLS * 2 : score;
for (int i = 0; i < pips; i++) {
tft.fillRect((int16_t)(OX + (i % COLS) * CELL), (int16_t)(y + (i / COLS) * 5), 8, 4,
ILI9341_YELLOW);
}
}
static void startGame() {
border(EDGE);
tft.fillRect(OX, OY, (int16_t)(COLS * CELL), (int16_t)(ROWS * CELL), BG);
length = 3;
tail = 0;
head = 2;
for (uint8_t i = 0; i < length; i++) {
body[i] = cellOf((uint8_t)(4 + i), (uint8_t)(ROWS / 2));
paint(body[i], i + 1 == length ? HEAD : SNAKE);
}
dx = 1;
dy = 0;
score = 0;
playing = true;
drawScore();
dropApple();
}
static void gameOver() {
playing = false;
mokxiTone(PIEZO_PIN, 220, 120);
mokxiTone(PIEZO_PIN, 160, 220);
border(ILI9341_RED);
Serial.print("game over, score ");
Serial.println(score);
}
// Read the stick and turn, refusing a reversal onto the snake's own neck.
static void steer() {
int8_t sx = stick.stepX();
int8_t sy = stick.stepY();
if (sx != 0 && dx == 0) {
dx = sx;
dy = 0;
} else if (sy != 0 && dy == 0) {
dy = sy;
dx = 0;
}
}
static void step() {
uint8_t from = body[head];
int8_t cx = (int8_t)(colOf(from) + dx);
int8_t cy = (int8_t)(rowOf(from) + dy);
if (cx < 0 || cy < 0 || cx >= (int8_t)COLS || cy >= (int8_t)ROWS) {
gameOver();
return;
}
uint8_t next = cellOf((uint8_t)cx, (uint8_t)cy);
bool eating = next == apple;
// The tail moves out of the way this step, so running onto it is allowed.
if (onSnake(next) && !(next == body[tail] && !eating)) {
gameOver();
return;
}
paint(from, SNAKE);
head = (uint8_t)((head + 1) % MAX_LEN);
body[head] = next;
paint(next, HEAD);
if (eating) {
score++;
drawScore();
mokxiTone(PIEZO_PIN, NOTE_C6, 30);
if (length < MAX_LEN - 1) {
length++;
}
dropApple();
} else {
paint(body[tail], BG);
tail = (uint8_t)((tail + 1) % MAX_LEN);
}
}
void setup() {
Serial.begin(115200);
Serial.println("Mokxi Uno: snake on an ILI9341");
Serial.println("push the stick to turn, press it to start again");
stick.begin();
mokxiNoTone(PIEZO_PIN);
tft.begin(0);
drawFurniture();
startGame();
}
void loop() {
if (!playing) {
if (stick.clicked()) {
startGame();
}
delay(20);
return;
}
// Steer all the way through the step so a flick is never missed.
unsigned long start = millis();
while (millis() - start < (unsigned long)STEP_MS) {
steer();
delay(5);
}
step();
}
Code walkthrough
The key functions of the sketch, and what each one does.
steer sketch.ino
Reads which way the stick is pushed. It only accepts a turn across the current direction: when moving sideways, only up or down; when moving up or down, only sideways. That blocks a reversal onto the snake's own neck.
// Read the stick and turn, refusing a reversal onto the snake's own neck.
static void steer() {
int8_t sx = stick.stepX();
int8_t sy = stick.stepY();
if (sx != 0 && dx == 0) {
dx = sx;
dy = 0;
} else if (sy != 0 && dy == 0) {
dy = sy;
dx = 0;
}
}step sketch.ino
Moves the snake one square. It works out the new head cell, and ends the game if that is off the board or on the snake. Then it paints the old head green and the new head cyan. If it ate the apple it grows and drops a new apple. Otherwise it erases the tail.
static void step() {
uint8_t from = body[head];
int8_t cx = (int8_t)(colOf(from) + dx);
int8_t cy = (int8_t)(rowOf(from) + dy);
if (cx < 0 || cy < 0 || cx >= (int8_t)COLS || cy >= (int8_t)ROWS) {
gameOver();
return;
}
uint8_t next = cellOf((uint8_t)cx, (uint8_t)cy);
bool eating = next == apple;
// The tail moves out of the way this step, so running onto it is allowed.
if (onSnake(next) && !(next == body[tail] && !eating)) {
gameOver();
return;
}
paint(from, SNAKE);
head = (uint8_t)((head + 1) % MAX_LEN);
body[head] = next;
paint(next, HEAD);
if (eating) {
score++;
drawScore();
mokxiTone(PIEZO_PIN, NOTE_C6, 30);
if (length < MAX_LEN - 1) {
length++;
// ... 7 more lines in the editordropApple sketch.ino
Picks a random cell with nextCell, a tiny shift-based random number generator, and tries again if the cell is on the snake. When it finds a free cell it paints the apple there.
static void dropApple() {
for (int tries = 0; tries < 200; tries++) {
uint8_t cell = nextCell();
if (!onSnake(cell)) {
apple = cell;
paint(apple, APPLE);
return;
}
}
}loop sketch.ino
While a game is running, it spends each STEP_MS window calling steer every 5 ms, so a quick flick is never missed. Then it calls step. When the game is over, it waits for a click on the stick.
void loop() {
if (!playing) {
if (stick.clicked()) {
startGame();
}
delay(20);
return;
}
// Steer all the way through the step so a flick is never missed.
unsigned long start = millis();
while (millis() - start < (unsigned long)STEP_MS) {
steer();
delay(5);
}
step();
}Troubleshooting
The usual mistakes with this circuit, and the fix for each.
- The screen is blank or white This project
RST and LED go to 5 V along with VCC. Check SCK, MOSI, CS and DC are on pins 13, 11, 10 and 9. - The snake turns the wrong way This project
VRX goes to A0 and VRY goes to A1. Swapping them swaps the axes. - The game is too fast or slow This project
ChangeSTEP_MS, the time in milliseconds between moves. - The screen stays white or black. TFT display
Check the SPI wiring: CS, DC, RST, MOSI, SCK. Power the module at 3.3 V logic, or use level shifting on 5 V boards. Call begin() and set the rotation. - Slow drawing or garbled colors. TFT display
Use hardware SPI pins and avoid redrawing the whole screen. Fill only changed areas. Confirm the library matches ILI9341 and the color order. - 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. - A passive piezo stays silent when you just set the pin HIGH. Piezo speaker
A passive piezo needs a changing signal. Use tone(pin, frequency) or a PWM wave. Around 2 to 4 kHz is loudest. A buzzer with a built in oscillator makes sound from plain DC instead.
Change it and keep it
Open it in the editor, change the circuit or the code, and keep your version in a free account.