Skip to content

Project: Snake Game

Recreate the classic arcade game on your OLED screen! Steer the snake with two buttons, eat the apples to grow longer, and try not to crash into the walls or your own tail. It speeds up as your score goes up.

Snake running on the OLED

Photo from the first edition. Follow the wiring table for pin numbers.

Objective

  • Button A turns the snake 90° to its left.
  • Button B turns the snake 90° to its right.
  • Each apple you eat makes the snake one square longer and adds a point.
  • Hitting a wall or the snake's own body ends the game.

The turns are relative to the way the snake is heading, not fixed up/down/left/right. So if the snake is heading down the screen, "left" sends it to your right. It takes a minute to get used to. That's part of the fun!

You'll need:

  • 1 × SSD1306 OLED screen (128 × 64, I2C)
  • 2 × push buttons
  • Jumper wires

Before you start

This project builds on OLED Screens and Buttons. Make sure button.py and ssd1306.py are saved on your ESP32 (see Saving Programs to the Board).

Wiring

Part Pin Connects to
OLED GND GND
VCC 3V3
SCL GPIO 22
SDA GPIO 21
Button A (turn left) one leg GPIO 14, other leg to GND
Button B (turn right) one leg GPIO 25, other leg to GND

Tip

Put Button A on the left and Button B on the right of your breadboard. Your fingers will thank you.

Plan it out

A game is just a loop that does three things over and over: read input, update the game, draw the screen.

  1. Set up the components and the game world. The screen is split into a 16-pixel score bar and a play area. The play area is a grid of 4 × 4 pixel squares, 32 across and 12 down. The snake is a list of the squares it covers.
  2. Break the job into functions.
    • new_game() resets the snake, score and speed.
    • place_apple() picks a random empty square.
    • check_buttons() turns the snake.
    • move_snake() moves the head forward one square, checks for crashes and apples, and removes the tail unless the snake just ate.
    • draw_game() and show_message() draw everything.
  3. Main loop: show a start screen, run the game until you crash, show your score, and repeat.

Code

Save this to your ESP32 as main.py, or click Run in Thonny.

snake.py
# Snake! Eat the apples, don't hit the walls or your own tail.
# Button A turns the snake left, Button B turns it right.
import time
import random
from machine import Pin, I2C
import ssd1306
from button import Button

TURN_LEFT_PIN = 14    # Button A
TURN_RIGHT_PIN = 25   # Button B
SDA_PIN = 21
SCL_PIN = 22

CELL = 4                   # each square of the snake is 4x4 pixels
HEADER = 16                # the score bar takes up the top 16 pixels
COLS = 128 // CELL         # 32 squares across
ROWS = (64 - HEADER) // CELL   # 12 squares down
START_SPEED_MS = 250       # time between moves at the start
FASTEST_SPEED_MS = 80      # it never gets faster than this

# Directions as (column change, row change). Rows count DOWN the screen.
# They're listed clockwise, so turning right = next in the list,
# and turning left = previous in the list.
DIRECTIONS = [(0, -1), (1, 0), (0, 1), (-1, 0)]   # up, right, down, left

left_button = Button(TURN_LEFT_PIN)
right_button = Button(TURN_RIGHT_PIN)
i2c = I2C(0, scl=Pin(SCL_PIN), sda=Pin(SDA_PIN))
display = ssd1306.SSD1306_I2C(128, 64, i2c)

# ---- game state (set up properly by new_game) ----
snake = []        # list of (col, row) squares; the head is the LAST one
heading = 0       # index into DIRECTIONS
turned = False    # only one turn per move, so you can't reverse into yourself
apple = (0, 0)
score = 0
speed_ms = START_SPEED_MS


def new_game():
    global snake, heading, turned, score, speed_ms
    middle = (COLS // 2, ROWS // 2)
    snake = [(middle[0], middle[1] + 1), middle]   # 2 squares, facing up
    heading = 0
    turned = False
    score = 0
    speed_ms = START_SPEED_MS
    place_apple()


def place_apple():
    # keep picking random squares until we find one the snake isn't on
    global apple
    while True:
        spot = (random.randint(0, COLS - 1), random.randint(0, ROWS - 1))
        if spot not in snake:
            apple = spot
            return


def check_buttons():
    global heading, turned
    if turned:
        return
    if left_button.was_pressed():
        heading = (heading - 1) % 4
        turned = True
    elif right_button.was_pressed():
        heading = (heading + 1) % 4
        turned = True


def move_snake():
    # returns False if the snake crashed
    global score, speed_ms, turned
    turned = False
    head_col, head_row = snake[-1]
    step_col, step_row = DIRECTIONS[heading]
    new_head = (head_col + step_col, head_row + step_row)

    hit_wall = not (0 <= new_head[0] < COLS and 0 <= new_head[1] < ROWS)
    # the tail moves out of the way this turn, so it doesn't count
    hit_self = new_head in snake[1:]
    if hit_wall or hit_self:
        return False

    snake.append(new_head)
    if new_head == apple:
        score += 1
        speed_ms = max(FASTEST_SPEED_MS, speed_ms - 10)   # speed up a bit
        place_apple()            # grow: we don't remove the tail
    else:
        snake.pop(0)             # same length: remove the tail
    return True


def draw_square(col, row):
    display.fill_rect(col * CELL, HEADER + row * CELL, CELL, CELL, 1)


def draw_game():
    display.fill(0)
    display.text("Score: " + str(score), 0, 4, 1)
    display.rect(0, HEADER - 1, 128, 1, 1)          # line under the score
    for col, row in snake:
        draw_square(col, row)
    # draw the apple as a hollow square so it stands out from the snake
    display.rect(apple[0] * CELL, HEADER + apple[1] * CELL, CELL, CELL, 1)
    display.show()


def show_message(line1, line2):
    display.fill_rect(8, 24, 112, 32, 0)
    display.rect(8, 24, 112, 32, 1)
    display.text(line1, 64 - len(line1) * 4, 30, 1)   # each letter is 8 px
    display.text(line2, 64 - len(line2) * 4, 42, 1)
    display.show()


def wait_for_button():
    while not (left_button.was_pressed() or right_button.was_pressed()):
        time.sleep_ms(10)


# ---- main loop ----
while True:
    new_game()
    draw_game()
    show_message("Snake!", "Press a button")
    wait_for_button()

    last_move = time.ticks_ms()
    alive = True
    while alive:
        check_buttons()          # checked every loop, so no press is missed
        if time.ticks_diff(time.ticks_ms(), last_move) >= speed_ms:
            last_move = time.ticks_ms()
            alive = move_snake()
            if alive:
                draw_game()
        time.sleep_ms(5)

    show_message("Game over!", "Score: " + str(score))
    time.sleep(2)

How it works

The snake is a list

snake holds (column, row) pairs, with the head at the end of the list. Moving is a neat trick: add a new head in front with append(), then chop off the tail with pop(0). The snake slides forward one square. When it eats an apple, the code simply skips removing the tail, so the snake grows by one.

Turning with a list of directions

DIRECTIONS = [(0, -1), (1, 0), (0, 1), (-1, 0)]   # up, right, down, left

Each direction is how much the column and row change per move. They're in clockwise order, so turning right just means moving to the next item in the list, and turning left means going back one. The % 4 wraps around the ends: from "left" (3), turning right gives (3 + 1) % 4 = 0, which is "up".

The code only allows one turn per move. Otherwise, two quick taps could spin the snake straight back into its own neck.

Timing without sleep()

If the loop slept for the whole time between moves, button presses during that time could be missed. Instead, the loop runs quickly and checks the buttons every time around. It only moves the snake once speed_ms milliseconds have passed since the last move:

if time.ticks_diff(time.ticks_ms(), last_move) >= speed_ms:

Each apple knocks 10 ms off speed_ms, down to a limit of 80 ms, so the game gets faster as you get better.

Result

Photos from the first edition. Follow the wiring table for pin numbers.

Make it your own

  • High score: keep the best score in a variable and show it on the game-over screen. For a real challenge, save it to a file so it survives a restart.
  • Sound effects: add the buzzer on GPIO 26 and play a quick beep when you eat an apple, and a sad low tone when you crash.
  • Wrap-around walls: instead of crashing into a wall, make the snake come out the opposite side (hint: % again!).
  • Four-button controls: add Buttons C and D for fixed up/down/left/right controls instead of relative turns.