/*
Press the Play button, then click the buttons.
Click inside the simulation window to enable WASD input.
Wokwi does not seem to support wifi on ESP-IDF,
so I could not enable MQTT functionality in the simulator.
Also it doesn't support component folders nor loose .c/.h pairs,
so I copied all button- and display-driving code to this file.
For better navigation (and to see the wifi/MQTT driver),
head to the repository:
https://gitlab.com/UpDownUp/esp-snake
Also, my high score is 35.
-Š
*/
#include <stdio.h>
#include <time.h>
#include <driver/spi_master.h>
#include <driver/gpio.h>
#include "freertos/FreeRTOS.h"
#include <freertos/task.h>
#define TURN_INTERVAL_MS 1000 // time between each move
#define BUTTS true
#define DOTMATRIX true
#define MQTT false // not working on wokwi
//////////////////////////////// --- GAME ENGINE
#define BORDER_SZ 8
/* For ease of conversion into MAX7219 dots,
grid is defined like this:
1 2 . . . 8
2
.
.
8
*/
typedef enum {
dir_N = 0,
dir_E = 1,
dir_S = 2,
dir_W = 3
} dir_t;
typedef struct {
uint8_t x;
uint8_t y;
} pos_t;
typedef struct {
pos_t pos[BORDER_SZ * BORDER_SZ];
uint8_t length;
dir_t dir;
} snake_t;
void move_snake(snake_t *snake) {
// Shift each 'pixel' on the snake by -1 position, except pos[0]
for (uint8_t i = snake->length - 1; i >= 1; i--) {
snake->pos[i] = snake->pos[i - 1];
}
// move snake.pos[0] based on direction
switch (snake->dir) {
case dir_N: snake->pos[0].y--; break;
case dir_E: snake->pos[0].x++; break;
case dir_S: snake->pos[0].y++; break;
case dir_W: snake->pos[0].x--; break;
}
}
// Generate a new fruit somewhere not on the snake body
pos_t move_fruit(snake_t snake) {
pos_t fruit;
bool collides;
do {
// Generate random position
fruit.x = (uint8_t) 1 + ( ((double) rand() / RAND_MAX) * (BORDER_SZ - 1));
fruit.y = (uint8_t) 1 + ( ((double) rand() / RAND_MAX) * (BORDER_SZ - 1));
printf("generated fruit x %d y %d\n", fruit.x, fruit.y);
collides = false;
// Check each snake pixel for collision
for (uint8_t i = 0; i < snake.length; i++) {
if (snake.pos[i].x == fruit.x && snake.pos[i].y == fruit.y) {
collides = true;
}
}
} while (collides == true);
return fruit;
}
// return true if game is over.
// Game is over if snake.pos[0] is out of bounds,
// Or has collided with other snake dots.
bool check_gameover_conditions(const snake_t snake) {
// Stage 1: Check if snake.pos[0] is within bounds
if (snake.pos[0].x <= BORDER_SZ && snake.pos[0].x > 0
&& snake.pos[0].y <= BORDER_SZ && snake.pos[0].y > 0) {
// If within bounds,
for (uint8_t i = 1; i < snake.length; i++) {
// check whether snake.pos[0] collision with other dots
if (snake.pos[0].x == snake.pos[i].x
&& snake.pos[0].y == snake.pos[i].y) {
return true;
}
}
// if not within bounds, game over
} else return true;
// if all checks passed without returning, good to go
return false;
}
//////////////////////////////// --- CONTROLS
#define BTN_UP GPIO_NUM_23
#define BTN_DN GPIO_NUM_5
#define BTN_L GPIO_NUM_22
#define BTN_R GPIO_NUM_18
gpio_num_t buttons[4] = {BTN_UP, BTN_DN, BTN_L, BTN_R};
volatile uint8_t button_pressed = 0;
static void gpio_isr_handler(void* arg) {
// Write the pressed button into memory,
// the main loop will process it in time.
button_pressed = *((uint8_t *) arg);
// I played around with fancy debouncing,
// but it's unnecessary in this project.
}
void button_config() {
gpio_install_isr_service(0);
for (uint8_t i = 0; i < 4; i++) {
gpio_config_t button_conf = {
.pin_bit_mask = 1 << buttons[i],
.mode = GPIO_MODE_INPUT,
.pull_up_en = GPIO_PULLUP_ENABLE,
.pull_down_en = GPIO_PULLDOWN_DISABLE,
.intr_type = GPIO_INTR_NEGEDGE // Interrupt on button press
};
ESP_ERROR_CHECK(gpio_config(&button_conf));
// *arg will contain the gpio_num_t of the button.
ESP_ERROR_CHECK(gpio_isr_handler_add(buttons[i], gpio_isr_handler, (void *) (buttons + i)));
}
}
//////////////////////////////// --- MAX7219
spi_device_handle_t spi_handle;
void max7219_init(void) {
/* In real life, there might be some additional steps
needed for init, like setting the intensity register.
Datasheet wasn't entirely clear about whether it's
visible at lowest intensity.
In the simulator it just werks
*/
// SPI bus configuration
spi_bus_config_t bus_config = {
.mosi_io_num = GPIO_NUM_26,
.sclk_io_num = GPIO_NUM_14,
};
// Initialize the SPI bus
spi_bus_initialize(VSPI_HOST, &bus_config, 0);
// SPI device configuration
spi_device_interface_config_t dev_config = {
.mode = 0, // MAX7219 has CPOL and CPHA == LOW
.clock_speed_hz = 10000000, // MAX7219 allows up to 10 MHz
.spics_io_num = GPIO_NUM_27,
.queue_size = 1
};
// Add the SPI device to the bus
spi_bus_add_device(VSPI_HOST, &dev_config, &spi_handle);
}
// digit must be 1-8. value must be 0 - 0xFF.
// Digit == row.
void write_digit(uint8_t digit, uint8_t value) {
// MAX7219 can be written to using a 2-byte bitfield,
// where byte 1 is the OR combination of set dot masks (1 << 0 to 1 << 7),
// and bits 8 - 11 (incl) are the register address of the row (0x01 to 0x08).
uint8_t bitfield[2] = {digit, value};
spi_transaction_t t = {0};
t.length = 16;
t.tx_buffer = bitfield;
spi_device_transmit(spi_handle, &t);
}
void max7219_render(const uint8_t *map) {
for (uint8_t i = 0; i < 8; i++) {
write_digit(i + 1, map[i]);
}
// With a clock speed of 10 MHz,
// the delay caused by this operation is trivial.
// If it were not, get_tick_count measurement
// can be used to adjust the delay.
}
//////////////////////////////// --- COMPONENT HELPERS
#if MQTT
// Only for MQTT. For MAX7219 display, see compose_map().
// Expects an at least 25-byte char array, empty, as input.
void compose_gamestate(char* map, const snake_t snake,
const pos_t fruit, const uint8_t turn) {
uint8_t bitmap[12] = {0};
// Bytes 0 - 7: Map grid
for (uint8_t i = 0; i < snake.length; i++) {
bitmap[snake.pos[i].y - 1] |= 1 << (snake.pos[i].x - 1);
}
// bitmap[8] contains the position of the fruit,
// encoded as 4 bits for x pos and 4 bits for y pos,
// in 1-8 range.
// Client side will interpret bitmap == 0 as no fruit.
if (fruit.x && fruit.y) {
bitmap[8] = ((fruit.x) << 4) + (fruit.y);
}
// bitmap[9] and [10] contain the
// LSB and MSB, respectively.
bitmap[9] = turn & 0xFF;
bitmap[10] = (turn >> 8) & 0xFF;
bitmap[11] = snake.length;
sprintf(map, "%.2X%.2X%.2X%.2X%.2X%.2X%.2X%.2X%.2X%.2X%.2X%.2X",
bitmap[0], bitmap[1], bitmap[2], bitmap[3],
bitmap[4], bitmap[5], bitmap[6], bitmap[7],
bitmap[8], bitmap[9], bitmap[10], bitmap[11]);
}
#endif
#if DOTMATRIX
// Only for MAX7219. For MQTT, see compose_gamestate().
// Writes 8-byte grid into uint8_t *map. Expects empty 8-byte map.
void compose_map(uint8_t *map, const snake_t snake, const pos_t fruit) {
// The fruit blinks, for ease of identification.
static bool show_fruit = true;
// first, add the fruit to where it should be,
// or hide it if blinking:
if (show_fruit) {
map[fruit.y - 1] |= 1 << (fruit.x - 1);
// "rows[dot.ypos] |= 1 << (dot.xpos - 1)" syntax
// is because each row is a 0x00-0xFF bitfield,
// each bit (0 to 7) representing a segment (dot).
}
show_fruit = !show_fruit;
// then, add each dot of the snake
for (uint8_t i = 0; i < snake.length; i++) {
map[snake.pos[i].y - 1] |= 1 << (snake.pos[i].x - 1);
}
}
#endif
void game_over(void) {
printf("it's over\n");
const uint8_t sadface[] = {
// thanks to xantorohara's LED Matrix editor
0x00, 0xe7, 0x00, 0x22, 0x00, 0x3c, 0x42, 0x81
// as before, sadface[0] is unused, using 1-8
};
#if MQTT
char sadface_mqtt[17] = {0};
sprintf(sadface_mqtt, "%.2X%.2X%.2X%.2X%.2X%.2X%.2X%.2X",
sadface[0], sadface[1], sadface[2], sadface[3],
sadface[4], sadface[5], sadface[6], sadface[7]);
// Send 00 as byte 8 to tell the client not to draw a fruit
mqtt_send(sadface_mqtt);
#endif
#if DOTMATRIX
max7219_render(sadface);
#endif
}
//////////////////////////////// --- MAIN
extern "C" void app_main() {
#if MQTT
ikl_wifi_init();
printf("Connecting... ");
while (conn_state != SUBSCRIBED_MQTT) {
vTaskDelay((100) / portTICK_PERIOD_MS);
}
printf("Online!");
#endif
#if DOTMATRIX
// Display setup
max7219_init();
#endif
#if BUTTS
// Controls setup
button_config();
#endif
// Setup randomization for fruit positioning
srand(time(NULL));
while(true) {
// Game setup
uint16_t turn = 0;
snake_t snake = {{{3, 7}, {3, 8}}, 2, dir_N};
pos_t fruit = {3, 4}; // first fruit is easy
/* HOW IT WORKS
At the beginning of each turn, game state is rendered.
The player is given an interval of time to react.
This interval is also used to blink the fruit.
At the end of the interval,
user input is processed and the game state is updated,
and the next turn starts.
*/
// Game loop
while (check_gameover_conditions(snake) == false) {
// 1. Display game state
printf("t = %d\t dir = %d 0.x = %d 0.y = %d f.x = %d f.y = %d\n"
"\t l = %d 1.x = %d 1.y = %d \n"
"\t\t 2.x = %d 2.y = %d \n\n",
turn, snake.dir, snake.pos[0].x, snake.pos[0].y, fruit.x, fruit.y,
snake.length, snake.pos[1].x, snake.pos[1].y,
snake.pos[2].x, snake.pos[2].y);
// Dot matrix display blinks the fruit to distinguish it.
uint8_t map[8] = {0};
const uint8_t blinks = 2;
for (uint8_t i = 0; i < blinks; i++) {
#if DOTMATRIX
compose_map(map, snake, fruit);
max7219_render(map);
memset(map, 0, 8);
#endif
vTaskDelay((TURN_INTERVAL_MS / blinks) / portTICK_PERIOD_MS);
}
#if MQTT
// frontend can display fruit with a different color,
// as long as it knows where it is.
char gamestate[25] = {0};
compose_gamestate(gamestate, snake, fruit, (uint8_t) turn);
mqtt_send(gamestate);
#endif
#if (MQTT || BUTTS)
// 2. Digest latest input
if (button_pressed) {
switch (button_pressed) {
// Do not allow turning 180 degrees
case (BTN_UP): if (snake.dir != dir_S) snake.dir = dir_N; break;
case (BTN_R): if (snake.dir != dir_W) snake.dir = dir_E; break;
case (BTN_DN): if (snake.dir != dir_N) snake.dir = dir_S; break;
case (BTN_L): if (snake.dir != dir_E) snake.dir = dir_W; break;
}
printf("butt %d pressed, dir now %d\n", button_pressed, snake.dir);
button_pressed = 0;
}
#endif
// 3. Update game state
move_snake(&snake);
// If snake eats fruit, increment length
if (snake.pos[0].x == fruit.x
&& snake.pos[0].y == fruit.y) {
snake.length++;
// remove the fruit for 1 turn after eating it
fruit.x = 0;
fruit.y = 0;
// after that, put it somewhere random
} else if (fruit.x == 0 && fruit.y == 0) {
fruit = move_fruit(snake);
}
turn++;
}
// show game over sadface for 3 s, then restart
game_over();
vTaskDelay(3000 / portTICK_PERIOD_MS);
}
// Unreachable, here for perfectionism's sake
// spi_bus_remove_device(spi_handle);
}