Compare commits

3 Commits

Author SHA1 Message Date
8e95e8d94f README 2026-06-16 22:51:08 -04:00
414f0684ab Snake works! Scoreboard is fucky. 2026-06-16 22:12:30 -04:00
8303ea9d10 WIP 2026-06-12 12:40:08 -04:00
6 changed files with 123 additions and 236 deletions

View File

@@ -16,33 +16,25 @@
#define ENTITY_FOOD 1 << 1
#define ENTITY_SNAKE 1 << 2
#define ENTITY_HIGH_MASK 248 // 11111000
#define ENTITY_LOW_MASK 7 // 00000111
#define ENTITY_SHIFT_LIFETIME 3
#define ENTITY_HIGH_MASK 240 // 11110000
#define ENTITY_LOW_MASK 15 // 00001111
int8_t player_x;
int8_t player_y;
int8_t delta_x;
int8_t delta_y;
// Entities represent a lit pixel on the LED matrix. Each entity is an 8 bit mask that consists of:
//
// 8 0
// 0 0 0 0 0 0 0 0
// ^ ^ ^ ^ ^
// | | | | `-- Alive or dead. When 1, display this entity.
// | | | `---- Food (1)
// | | `------ Snake (1)
// | |
// `-------+-------- Lifetime. How many game cycles should this entity remain for? 0-248.
// ^ ^ ^ ^ ^ ^
// | | | | | `-- Alive or dead. When 1, display this entity.
// | | | | `---- Food (1)
// | | | `------ Snake (1)
// | | `-------- Reserved
// `-----+---------- Lifetime. How many game cycles should this entity remain for? 0-128.
// The lifetime gets counted down each cycle!
#define GAMESTATE_SCREENCAL 1
#define GAMESTATE_PLAY 2
#define GAMESTATE_LOSE 3
#define GAMESTATE_WIN 4
uint8_t entities[64] = {0};
int found_food = 0;
int8_t tail_length = 2;
@@ -57,8 +49,6 @@ Button btn_up = {PIN_BUTTON_UP, 0, LOW, BUTTON_STABLETIME, 0};
Button btn_down = {PIN_BUTTON_DOWN, 0, LOW, BUTTON_STABLETIME, 0};
Button btn_right = {PIN_BUTTON_RIGHT, 0, LOW, BUTTON_STABLETIME, 0};
uint8_t gamestate = 0;
void initSerial()
{
Serial.begin(115200);
@@ -96,11 +86,12 @@ void collide_player()
if ( entity > 0 ) {
// We die if we hit our own body
if ( (entity & ENTITY_SNAKE) == ENTITY_SNAKE ) {
Serial.printf("Hit entity %d (%d) and died!", (player_x * 8) + player_y, entity);
ledcWriteTone(PIN_BUZZER, 2000);
delay(1000);
ledcWriteTone(PIN_BUZZER, 0);
init_game();
} else if ( (entity & ENTITY_FOOD) == ENTITY_FOOD ) {
Serial.printf("Found food!\n");
found_food = 1;
score += 1;
}
@@ -111,24 +102,15 @@ void draw_entities()
{
uint8_t entity;
resetMatrix();
if ( gamestate == GAMESTATE_SCREENCAL ) {
for ( int i = 0 ; i < 64 ; i++ ) {
// Poke matrix pixels for every living entity
for ( int i = 0 ; i < 64; i++ ) {
entity = entities[i];
if ( entity & ENTITY_ALIVE == ENTITY_ALIVE ) {
if ( pokeMatrixPixel((i / 8), (i % 8)) != ERRNO_SUCCESS ) {
Serial.printf("Failed to poke pixel : %d\n", i, errno);
Serial.printf("Failed to poke pixel for entity %d : %d\n", i, errno);
return;
}
}
} else {
// Poke matrix pixels for every living entity
for ( int i = 0 ; i < 64; i++ ) {
entity = entities[i];
if ( ( entity & ENTITY_ALIVE ) == ENTITY_ALIVE ) {
if ( pokeMatrixPixel((i / 8), (i % 8)) != ERRNO_SUCCESS ) {
Serial.printf("Failed to poke pixel for entity %d : %d\n", i, errno);
return;
}
}
}
}
displayMatrix();
}
@@ -140,22 +122,24 @@ void update_matrix_entities()
// Poke matrix pixels for every living entity
for ( int i = 0 ; i < 64; i++ ) {
entity = entities[i];
if ( (entity & ENTITY_ALIVE) == ENTITY_ALIVE && i != ((player_x * 8) + player_y) ) {
if ( entity & ENTITY_ALIVE == ENTITY_ALIVE && i != ((player_x * 8) + player_y) ) {
// Snake parts age (except the head)
if ( (entity & ENTITY_SNAKE) == ENTITY_SNAKE ) {
elife = (entity & ENTITY_HIGH_MASK) >> ENTITY_SHIFT_LIFETIME;
if ( entity & ENTITY_SNAKE == ENTITY_SNAKE ) {
elife = (entity & ENTITY_HIGH_MASK) >> 4;
Serial.printf("Entity %d (%d) has lifetime %d\n", i, entity, elife);
if ( found_food == 0 ) {
// Count down their lifetime
if ( elife == 0 ) {
Serial.printf("Entity %d died\n", i);
entity = 0;
} else {
elife -= 1;
entity = (elife << ENTITY_SHIFT_LIFETIME) | (entity & ENTITY_LOW_MASK);
entity = (elife << 4) | (entity & ENTITY_LOW_MASK);
}
entities[i] = entity;
} /*else {
elife += 1;
entity = (elife << ENTITY_SHIFT_LIFETIME) | (entity & ENTITY_LOW_MASK);
entity = (elife << 4) | (entity & ENTITY_LOW_MASK);
entities[i] = entity;
}*/
}
@@ -163,6 +147,12 @@ void update_matrix_entities()
}
}
void wingame()
{
Serial.printf("The player won the game!\n");
}
void place_random_entity(int8_t eflags, int8_t set_player)
{
uint8_t index = 0;
@@ -175,17 +165,18 @@ void place_random_entity(int8_t eflags, int8_t set_player)
for ( int i = 0 ; i < 64; i++ ) {
entity = entities[i];
if ( entity == 0) {
open_indexes[open_positions] = i;
Serial.printf("Entity %d is available (%d)\n", i, entity);
open_positions += 1;
open_indexes[open_positions] = i;
} else {
Serial.printf("Entity %d is alive (%d)\n", i, entity);
}
}
Serial.printf("There are %d positions open on the board\n", open_positions);
if ( open_positions == 0 ) {
// This is a win condition!
gamestate = GAMESTATE_WIN;
delta_x = 0;
delta_y = 0;
wingame();
return;
}
@@ -203,6 +194,8 @@ void place_random_entity(int8_t eflags, int8_t set_player)
player_x = (index / 8);
player_y = (index % 8);
}
Serial.printf("Placed entity at %d (%d)\n", (index / 8) + (index % 8), entities[index]);
Serial.printf("Done\n");
}
void init_game()
@@ -211,14 +204,12 @@ void init_game()
memset((void *)&entities, 0x00, 64);
gamespeed = 1000;
lastupdate = millis();
score = 0;
delta_x = 0;
delta_y = 0;
place_random_entity(ENTITY_HIGH_MASK | ENTITY_ALIVE | ENTITY_FOOD, 0);
// Place the player away from the food
place_random_entity(((tail_length << ENTITY_SHIFT_LIFETIME) | ENTITY_ALIVE | ENTITY_SNAKE), 1);
Serial.printf("Placing player\n");
place_random_entity(((tail_length << 4) | ENTITY_ALIVE | ENTITY_SNAKE), 1);
}
void read_controls()
@@ -231,30 +222,23 @@ void read_controls()
Serial.printf("Failed to read buttons\n");
return;
}
if ( gamestate == GAMESTATE_SCREENCAL ) {
if ( btn_up.state + btn_down.state + btn_left.state + btn_right.state != 0 ) {
init_game();
gamestate = GAMESTATE_PLAY;
}
} else {
// We don't let the player reverse direction back on themselves.
if ( (delta_x == 0) && (btn_up.state & BUTTON_STATE_DOWN) == BUTTON_STATE_DOWN ) {
delta_x = 1;
delta_y = 0;
}
if ( (delta_x == 0) && (btn_down.state & BUTTON_STATE_DOWN) == BUTTON_STATE_DOWN ) {
delta_x = -1;
delta_y = 0;
}
if ( (delta_y == 0) && (btn_right.state & BUTTON_STATE_DOWN) == BUTTON_STATE_DOWN ) {
delta_y = -1;
delta_x = 0;
}
if ( (delta_y == 0) && (btn_left.state & BUTTON_STATE_DOWN) == BUTTON_STATE_DOWN ) {
delta_y = 1;
delta_x = 0;
}
// We don't let the player reverse direction back on themselves.
if ( (delta_x == 0) && (btn_up.state & BUTTON_STATE_DOWN) == BUTTON_STATE_DOWN ) {
delta_x = 1;
delta_y = 0;
}
if ( (delta_x == 0) && (btn_down.state & BUTTON_STATE_DOWN) == BUTTON_STATE_DOWN ) {
delta_x = -1;
delta_y = 0;
}
if ( (delta_y == 0) && (btn_right.state & BUTTON_STATE_DOWN) == BUTTON_STATE_DOWN ) {
delta_y = -1;
delta_x = 0;
}
if ( (delta_y == 0) && (btn_left.state & BUTTON_STATE_DOWN) == BUTTON_STATE_DOWN ) {
delta_y = 1;
delta_x = 0;
}
}
void display_score()
@@ -273,24 +257,6 @@ void display_score()
};
uint8_t score_cur = 0;
int rc = 0;
if ( gamestate == GAMESTATE_SCREENCAL ) {
select7SegDisplay(PIN_7SEGMENT_DIGIT4);
rc = writeIC74HC595Char(&scoreboard, CHAR_7SEG_C, MSBFIRST);
rc += writeIC74HC595Char(&scoreboard, CHAR_7SEG_OFF, MSBFIRST);
select7SegDisplay(PIN_7SEGMENT_DIGIT3);
rc += writeIC74HC595Char(&scoreboard, CHAR_7SEG_A, MSBFIRST);
rc += writeIC74HC595Char(&scoreboard, CHAR_7SEG_OFF, MSBFIRST);
select7SegDisplay(PIN_7SEGMENT_DIGIT2);
rc += writeIC74HC595Char(&scoreboard, CHAR_7SEG_L, MSBFIRST);
rc += writeIC74HC595Char(&scoreboard, CHAR_7SEG_OFF, MSBFIRST);
if ( rc != ERRNO_SUCCESS ) {
Serial.printf("Failed to write scoreboard: %d", errno);
return;
}
return;
}
if ( difficulty > 0 ) {
gamespeed = 1000 / difficulty;
} else {
@@ -299,7 +265,6 @@ void display_score()
score_cur = (score % 10);
select7SegDisplay(PIN_7SEGMENT_DIGIT1);
rc = writeIC74HC595Char(&scoreboard, integerValues[score_cur], MSBFIRST);
rc += writeIC74HC595Char(&scoreboard, CHAR_7SEG_OFF, MSBFIRST);
if ( rc != ERRNO_SUCCESS ) {
Serial.printf("Failed to write scoreboard: %d", errno);
return;
@@ -308,15 +273,20 @@ void display_score()
score_cur = (score / 10);
select7SegDisplay(PIN_7SEGMENT_DIGIT2);
rc = writeIC74HC595Char(&scoreboard, integerValues[score_cur], MSBFIRST);
rc += writeIC74HC595Char(&scoreboard, CHAR_7SEG_OFF, MSBFIRST);
if ( rc != ERRNO_SUCCESS ) {
Serial.printf("Failed to write scoreboard: %d", errno);
return;
}
select7SegDisplay(PIN_7SEGMENT_DIGIT3);
rc = writeIC74HC595Char(&scoreboard, 0x00, MSBFIRST);
if ( rc != ERRNO_SUCCESS ) {
Serial.printf("Failed to write scoreboard: %d", errno);
return;
}
select7SegDisplay(PIN_7SEGMENT_DIGIT4);
rc = writeIC74HC595Char(&scoreboard, integerValues[difficulty], MSBFIRST);
rc += writeIC74HC595Char(&scoreboard, CHAR_7SEG_OFF, MSBFIRST);
if ( rc != ERRNO_SUCCESS ) {
Serial.printf("Failed to write scoreboard: %d", errno);
return;
@@ -327,8 +297,8 @@ void gamelogic()
{
uint32_t updatetime = millis();
found_food = 0;
Serial.printf("Score is %d\n", score);
if ( (updatetime - lastupdate) > gamespeed ) {
Serial.printf("Updating game logic. Difficulty %d (%d elapsed / gamespeed %d). Player <%d, %d>.\n", difficulty, (updatetime - lastupdate), gamespeed, player_x, player_y);
lastupdate = updatetime;
// Skip all of this if we have no delta (the player isn't playing yet)
if ( (delta_x + delta_y) != 0 ) {
@@ -339,7 +309,7 @@ void gamelogic()
tail_length += 1;
place_random_entity(ENTITY_HIGH_MASK | ENTITY_ALIVE | ENTITY_FOOD, 0);
}
entities[(player_x * 8) + player_y] = ((tail_length << ENTITY_SHIFT_LIFETIME) | ENTITY_ALIVE | ENTITY_SNAKE );
entities[(player_x * 8) + player_y] = ((tail_length << 4) | ENTITY_ALIVE | ENTITY_SNAKE );
}
update_matrix_entities();
found_food = 0;
@@ -348,7 +318,7 @@ void gamelogic()
void setup() {
initSerial();
score = 0;
if ( init74HC595(&matrixcols) != ERRNO_SUCCESS ) {
Serial.printf("failed to initialize matrix series 74HC595");
}
@@ -366,7 +336,6 @@ void setup() {
}
initBuzzer();
init_game();
gamestate = GAMESTATE_SCREENCAL;
Serial.printf("\nSETUP READY\n");
}

View File

@@ -14,26 +14,45 @@
// Other digits that include the bottom segment (like 2, 0, 8) and that don't include it
// (like 7, 9, 4) work fine.
#define CHAR_7SEG_0 (uint8_t) ~( SEG_A | SEG_B | SEG_C | SEG_D | SEG_E | SEG_F)
#define CHAR_7SEG_1 (uint8_t) ~( SEG_B | SEG_C)
#define CHAR_7SEG_2 (uint8_t) ~( SEG_A | SEG_B | SEG_G | SEG_E | SEG_D)
#define CHAR_7SEG_3 (uint8_t) ~( SEG_A | SEG_B | SEG_C | SEG_D | SEG_G )
#define CHAR_7SEG_4 (uint8_t) ~( SEG_F | SEG_G | SEG_B | SEG_C)
#define CHAR_7SEG_5 (uint8_t) ~( SEG_A | SEG_F | SEG_G | SEG_C | SEG_D)
#define CHAR_7SEG_6 (uint8_t) ~( SEG_A | SEG_F | SEG_E | SEG_D | SEG_C | SEG_G)
#define CHAR_7SEG_7 (uint8_t) ~( SEG_A | SEG_B | SEG_C)
#define CHAR_7SEG_8 (uint8_t) ~( SEG_A | SEG_B | SEG_C | SEG_D | SEG_E | SEG_F | SEG_G)
#define CHAR_7SEG_9 (uint8_t) ~( SEG_A | SEG_B | SEG_C | SEG_F | SEG_G)
#define CHAR_7SEG_0 (uint8_t) ~(SEG_DP | SEG_A | SEG_B | SEG_C | SEG_D | SEG_E | SEG_F)
#define CHAR_7SEG_1 (uint8_t) ~(SEG_DP | SEG_B | SEG_C)
#define CHAR_7SEG_2 (uint8_t) ~(SEG_DP | SEG_A | SEG_B | SEG_G | SEG_E | SEG_D)
#define CHAR_7SEG_3 (uint8_t) ~(SEG_DP | SEG_A | SEG_B | SEG_C | SEG_D | SEG_G )
#define CHAR_7SEG_4 (uint8_t) ~(SEG_DP | SEG_F | SEG_G | SEG_B | SEG_C)
#define CHAR_7SEG_5 (uint8_t) ~(SEG_DP | SEG_A | SEG_F | SEG_G | SEG_C | SEG_D)
#define CHAR_7SEG_6 (uint8_t) ~(SEG_DP | SEG_A | SEG_F | SEG_E | SEG_D | SEG_C | SEG_G)
#define CHAR_7SEG_7 (uint8_t) ~(SEG_DP | SEG_A | SEG_B | SEG_C)
#define CHAR_7SEG_8 (uint8_t) ~(SEG_DP | SEG_A | SEG_B | SEG_C | SEG_D | SEG_E | SEG_F | SEG_G)
#define CHAR_7SEG_9 (uint8_t) ~(SEG_DP | SEG_A | SEG_B | SEG_C | SEG_F | SEG_G)
#define CHAR_7SEG_A (uint8_t) ~( SEG_A | SEG_B | SEG_C | SEG_E | SEG_F | SEG_G)
#define CHAR_7SEG_B (uint8_t) ~( SEG_C | SEG_D | SEG_E | SEG_F | SEG_G)
#define CHAR_7SEG_C (uint8_t) ~( SEG_A | SEG_D | SEG_E | SEG_F)
#define CHAR_7SEG_D (uint8_t) ~( SEG_C | SEG_D | SEG_E | SEG_B | SEG_G)
#define CHAR_7SEG_E (uint8_t) ~( SEG_A | SEG_D | SEG_E | SEG_F | SEG_G)
#define CHAR_7SEG_F (uint8_t) ~( SEG_A | SEG_E | SEG_F | SEG_G)
#define CHAR_7SEG_L (uint8_t) ~( SEG_D | SEG_E | SEG_F)
#define CHAR_7SEG_A (uint8_t) ~(SEG_DP | SEG_A | SEG_B | SEG_C | SEG_E | SEG_F | SEG_G)
#define CHAR_7SEG_B (uint8_t) ~(SEG_DP | SEG_C | SEG_D | SEG_E | SEG_F | SEG_G)
#define CHAR_7SEG_C (uint8_t) ~(SEG_DP | SEG_A | SEG_D | SEG_E | SEG_F)
#define CHAR_7SEG_D (uint8_t) ~(SEG_DP | SEG_C | SEG_D | SEG_E | SEG_B | SEG_G)
#define CHAR_7SEG_E (uint8_t) ~(SEG_DP | SEG_A | SEG_D | SEG_E | SEG_F | SEG_G)
#define CHAR_7SEG_F (uint8_t) ~(SEG_DP | SEG_A | SEG_E | SEG_F | SEG_G)
#define CHAR_7SEG_L (uint8_t) ~(SEG_D | SEG_E | SEG_F)
#define CHAR_7SEG_DP (uint8_t) ~SEG_DP
#define CHAR_7SEG_OFF 0xFF
/* #define CHAR_7SEG_0 0xC0 // 0b11000000 */
/* #define CHAR_7SEG_1 0xF9 // 0b11111001 */
/* #define CHAR_7SEG_2 0xA4 // 0b10100100 */
/* #define CHAR_7SEG_3 0xB0 // 0b10110000 */
/* #define CHAR_7SEG_4 0x99 // 0b10011001 */
/* #define CHAR_7SEG_5 0x92 // 0b10010010 */
/* #define CHAR_7SEG_6 0x82 // 0b10000010 */
/* #define CHAR_7SEG_7 0xF8 // 0b11111000 */
/* #define CHAR_7SEG_8 0x80 // 0b10000000 */
/* #define CHAR_7SEG_9 0x90 // 0b10010000 */
/* #define CHAR_7SEG_A 0x88 // 0b10001000 */
/* #define CHAR_7SEG_B 0x83 // 0b10000011 */
/* #define CHAR_7SEG_C 0xC6 // 0b11000110 */
/* #define CHAR_7SEG_D 0xA1 // 0b10100001 */
/* #define CHAR_7SEG_E 0x86 // 0b10000110 */
/* #define CHAR_7SEG_F 0x8E // 0b10001110 */
/* #define CHAR_7SEG_DP 0x80 // 0b10000000 */
/* #define CHAR_7SEG_OFF 0x00 // 0b00000000 */
#define PIN_7SEGMENT_DIGIT1 7
#define PIN_7SEGMENT_DIGIT2 6

View File

@@ -1,65 +1,22 @@
# Breadboard
![breadboard](breadboard.png)
This project uses 3 74HC595 serial to parallel shift registers, a 7-segment display multi unit, an 8x8 LED matrix, an active buzzer, an NPN transistor, a joystick (later replaced with 4 buttons), a handful of resistors and a potentiometer to make a playable Snake game.
This was easily the most challenging project so far, taking me almost two weeks to get it working right. I wound up having to break the project out across 4 separate breadboards. The breadboards look as gross in real life as it does in Fritzing.
[Here is a video of it working](https://x.com/AKLabsDotNet/status/2067645849258103188?s=20)
This was easily the most challenging project so far, taking me well over a week to get it working right.
# Lessons Learned
* C has admitted design flaws
* Hardware debouncing is definitely the way to go
* The Arduino IDE debugger really sucks
* It's been a long time since I had to think this hard about what my code was doing to the hardware
* Compiling, uploading, and debugging Arduino code from the CLI
* Breadboards probably introduce just as many bugs as I do
* How to drive a 7-segment display and an 8x8 LED matrix using shift registers
* How to drive a 7-segment display
* How to drive an 8x8 LED matrix
* Embedded platforms have undocumented limits that may surprise you
* You don't need lots of pixels, or even colors, to make it fun
## C design flaw : the binary operators in comparison
If you've followed my blog much at all, you will know that I am of the opinion that, as a programming language, [C is basically perfect. (Fight me)](https://aklabs.net/2026/01/10/libakerror/#There-is-no-problem-C-is-not-broken). But ... it does have quirks. And one of those quirks had me scratching my head from the time I wrote the game logic to the very end.
If you haven't played a snake game before, it is a simple thing. You have a snake, and you have food. The snake moves around the board and eats the food. The food stays on the board until the snake eats it. When the snake eats the food, the snake gets longer. If the snake runs into its own body, the game ends. If the snake gets so big that there is no longer space for food to spawn on the game board, you win.
In my snake game there are two kinds of entities on the game board: snake and food. Entities have a certain lifetime, and when they reach the end of that lifetime, they disapear from the screen. Food, however, is never supposed to disappear. It stays until the snake eats it.
Initially my code was counting down the lifetime on food entities, and I couldn't figure out why. Can you spot the bug?
```arduino
if ( entity & ENTITY_ALIVE == ENTITY_ALIVE && i != ((player_x * 8) + player_y) ) {
// Snake parts age (except the head)
if ( entity & ENTITY_SNAKE == ENTITY_SNAKE ) {
```
The intent is obvious:
- If an entity is alive
- AND the entity is not the player's current position
- AND the entity is a snake body part
- ... Decrement its lifetime
As it turns out, I had fallen victim to a classic blunder without realizing it. The `&` operator has a lower precedence than the `==` operator. So the compiler was actually generating an evaluation that looked like this:
```arduino
if ( entity && (ENTITY_SNAKE == ENTITY_SNAKE) ) {
```
... what the hell? That's not what I meant! But according to [The Man Himself](https://www.lysator.liu.se/c/dmr-on-or.html), this is a long standing design flaw in the C language:
> Early C had no separate operators for & and && or | and ||. [...] Instead it used the notion (inherited from B and BCPL) of "truth-value context": where a Boolean value was expected, after "if" and "while" and so forth, the & and | operators were interpreted as && and || are now; in ordinary expressions, the bitwise interpretations were used.
In other words, inside of a comparison operation (`if` or `switch` or `for` or `while`), `&` and `|` get evaluated AFTER the comparison operators (`<, >, <=, >=, ==, !=` etc). He admitted and lamented this design choice, but hey, it's already out in the wild, so we aren't changing it.
Parenthetize your bitwise operations inside of comparisons, and you'll be just fine. Don't let this happen to you, kids.
## Compiling, uploading, and debugging from the CLI
The Arduino IDE is ... not great. Its editor is crummy; I edit my code in Emacs and just use the IDE to upload, monitor and debug it. And frankly its debugger is not that great - I find most GUI debuggers to be kind of clunky, and this one especially so.It *works*, but I would say, *just barely*. The IDE is great for finding and adding libraries and things like that, but I don't like being constrained inside of it. Luckily the IDE is mostly just calling `arduino-cli` behind the scenes so everything the IDE does, we can do from the CLI. Setting up the arduino-cli is reasonably simple, but it quickly falls apart when you want to start debugging.
The Arduino IDE is ... not great. Its editor is crummy; I edit my code in Emacs and just use the IDE to upload, monitor and debug it. And frankly its debugger is not that great - I find most GUI debuggers to be kind of clunky, and this one especially. It's great for finding and adding libraries and things like that, but I don't like being constrained inside of it. Luckily the IDE is mostly just calling `arduino-cli` behind the scenes so everything the IDE does, we can do from the CLI. Setting up the arduino-cli is reasonably simple, but it quickly falls apart when you want to start debugging.
To set up the ESP32S3 dev board:
@@ -173,8 +130,6 @@ For example I had a problem with the LCD 7 segment display where segment D (the
By Day 4 of the project the breadboard had defeated me. The LED matrix had a short of some kind very similar to what the 7 segment LCD had. Some columns didn't work, some rows didn't work, and bumping the boards and resistors caused things to behave strangely. With great reluctance, I pulled everything apart, rewired the LED matrix and 7 segment display on their own breadboards away from the main ESP32 breakout and the potentiometer, cut down all the resistor wires for flush mounting, and that resolved all the issues.
The issues with loose connections persisted. I had the game basically done but I was still struggling with rows or columns on the 8x8 mysteriously disappearing. i would bump the breadboard, and it would come back. The worst part was that sometimes the game would start with a dead row or column and you would have no idea until your snake entered it, or you found some food in it by accident. I wound up introducing a calibration state at the beginning of the game that did nothing more than light up all the pixels on the matrix, so you could make sure they were all lighting up before playing the game.
## Joysticks and ADC and wierd outputs
Once I had the 7 segment and LED matrix driver finally working (able to plot arbitrary pixels on the LED matrix), I attached the joystick so the user could control the snake. Attached the joystick X and Y to pins 1 and 2, power and ground, Z to 47, and ... the shift registers driving the 7 segment and matrix would see ADC values output to them from their signal pins whenever joystick X or Y went positive.
@@ -195,10 +150,6 @@ I didn't actually wind up figuring this out. I wrestled with it for a bit, check
... Once I got all that working, I discovered that the wire between the potentiometer and pin 10 was bad. No continuity through the wire. Had it been that way all along? I don't think so. But sure as shit it's dead now. Replaced that and the wierd analog readings on the potentiometer went away. I had already ripped out the joystick handling code, so I didn't try putting it back in. There's no turning back now.
## Debouncing is a much bigger problem than I thought
When I made the switch from a joystick to 4 d-pad style buttons, I reused my button debounce code from previous projects. It seemed to work pretty well. However, when I wired up 4 buttons on a breadboard, and got into a situation where I needed to have my fingers resting on them all the time to be ready to change the direction of the snake, I figured out how much of a problem software debouncing really is. Those buttons were *constantly* firing when I didn't want them to. Even just holding my fingers NEAR the buttons would sometimes cause them to start bouncing. Sometimes interference from neighboring wires would do it on their own! Eventually I just learned to live with it, but the next time I do a project like this, I think I'm going to use a 74HC14 or something similar as a hardware debouncer before my software even sees that signal. Super noisy! I really wanted to integrate them on this project, but honestly I didn't want to touch the breadboards again once I had all the wires working, so I just learned to live with it.
## Arduino IDE debugger woes and ESP32 limits
I spent about an hour struggling with a `Stack smashing protection` fault. Try as I might, I couldn't get the Arduino IDE debugger to show me where the actual crash was in my code. All I was getting was a fault with some addresses in memory for a backtrace. Sure, I could objdump the binary, figure out the addresses, and figure it out that way. But if I'm gonna do that, WTF is the point of having a GUI debugger? There are some GUI tools for decoding ESP32 stacktraces, but the Arduino IDE integration for them hasn't worked in at least one whole major version, and the newer versions want to run in some IDE I don't want to use.
@@ -207,81 +158,28 @@ I wound up figuring it out essentially through trial and error. The `place_rando
I'm sure there was a more elegant solution than just moving it in to a global variable. But at this point, 9 days in to the project, lol bitch please, you're getting a global variable and we're making this damn thing run.
## Driving 7-segment displays and 8x8 matrixes with shift registers
## Driving 7-segment displays
These 7 segment LCD displays have an odd relationship between their pins and the segments of the LCD. You might think that you would start at pin 1 and proceed in some kind of logical fashion, clockwise or counterclockwise, around the device. Nope. The pins are all over the place; I assume because that's the most logical place for the physical connections to be, and putting them somewhere else would be inconvenient for the chip designer. Wiring them is kind of a pain, especially when you have a lot on the breadboard - especially depending on the kind of jumper wires you're using. Then there's the fact that you would need 8 individual GPIO lines to light up a single 7 segment LED; if you wanted to run a 4-cell unit (like I have on this game), then you need 12 GPIO pins. Needless to say, we can't sacrifice that many GPIOs.
These 7 segment LCD displays have an odd relationship between their pins and the segments of the LCD. You might think that you would start at pin 1 and proceed in some kind of logical fashion, clockwise or counterclockwise, around the device. Nope. The pins are all over the place. Wiring them is kind of a pain, especially when you have a lot on the breadboard.
That's where the 74HC595 shift register comes in. It's a delightful little IC that takes in 8 bits over serial input, and reproduces them in parallel on the output. So you can use 3 pins (one data, one clock, and one latch) to do the job that used to require 8. So we throw a 74HC595 between the ESP32 and the 7 segment matrix, wire up the output enable pin to ground (because we essentially always want it to be enabled), give it some voltage, and voila - we're pushing 8 bits of parallel data with 8 bits of serial input.
* Single units
* Multiple units
When I started this project, I had initially planned to write my own driver for the 74HC595's serial protocol, instead of using the builtin Arduino `shiftOut()` function. However there was so much time spent chasing other gremlins in this project that I ultimately abandoned this idea. I'll have to tinker with that some other time.
## Driving an 8x8 LED matrix
One caveat to working with these 7 segment LED matrixes (beyond the wierd pin layout): when working with multiple cells, you need to turn the cell OFF immediately after you write to it, before going to the next one. If you don't, you get really wierd ghosting artifacts across the matrix. I'm not sure why that happens; but it does.
One neat thing about these 74HC595 units is that you can daisy chain them together to create larger shift registers. By hooking the QH pin of the first IC to the serial in of the next IC, whenever we overflow past bit 7 on the first shift register, we begin pushing bits out of the first shift register into the second one. This allows us to essentially treat the daisy chain of 74HC595s like one big register of 16, or 24, or 32, or whatever bits width. And we never have to use more than 3 GPIOs; they all use the same clock, the same latch enable, and the same data pin. The ones wired further downstream in series just pull their data from the QH pin of the IC before them.
When I was originally designing the circuit for this, I intentionally didn't look at the upstream tutorial for using 74HC595s and tried to just figure it out from the datasheet. The datasheet did *not* make it clear to me that they could be wired in series like that - even going back now with that knowledge I struggle to see it in the datasheet. So I wound up with them sharing clock and latch signals, but they each had their own data pin, and I would push data to them individually. Obviously, this caused all kinds of problems; since I would only push to 1 of the 2 at a time, the one not receiving data would see the latch tripped, pull data from its serial pin, get noise, and corrupt itself. I struggled with this for a lot longer than I care to admit before I finally gave up, went and read the tutorial, wired it up exactly the way they had it, then stared at it for a while before realising how they had done the series wiring. When the light bulb finally came on, it was blinding.
Wiring them in series is how the 8x8 LED matrix is driven. In this project, the 8x8 LED matrix is used like a very small screen, with each position on it treated like a pixel. Again, the wiring I have is what was in the tutorial, because I spent so much time struggling with my own wiring that I just accepted what the tutorial had and went with it. Unfortunately that leads to an odd little convention in the code for displaying a row on the matrix:
```arduino
void writeLEDMatrix(uint8_t row, uint8_t col)
{
digitalWrite(matrixcols.updatepin, LOW);
shiftOut(matrixcols.datapin, matrixcols.clockpin, LSBFIRST, row);
shiftOut(matrixcols.datapin, matrixcols.clockpin, MSBFIRST, col);
digitalWrite(matrixcols.updatepin, HIGH);
}
```
You might notice that I'm reversing the bit order on the two `shiftOut` calls. No, that's not a bug, and until the very end o the project, I didn't really understand why it's doing it that way. Apparently the wiring tutorial I followed, for some reason, reversed the order of the wiring for columns, such that pin 1 of the shift register went to column 8 of the matrix. I presume they did this because, on their breadboard, that made the wiring make more sense. That wasn't necessary in my case, but since I followed their wiring, I adopted the convention without really realizing it. Because the wiring on those columns is backwards, the bit ordering on the columns has to be flipped.
I didn't figure this out until almost all other bugs were resolved. And by that time, I'll be damned if I'm gonna go in there and rip out wiring. It works this way, and it'll live this way.
Regardless, now that we had a pair of 8 bit registers that would allow us to control all the pixels on the 8x8 matrix, we could treat it like a tiny framebuffer. It might seem intuitive to say "we will select row X in the row shift register, and column Y in the column shift register, and away we go". But no... That's not how this hardware works. When you're driving hardware with software, you have to remember that the interface you're working with was designed primarily with the HARDWARE engineer's set of tradeoffs in mind. It's the software's job to adapt to the hardware interface, not the other way around. And in this case, the 8x8 matrix is either a common anode or a common cathode array of LEDs, meaning that all the LEDs on a row or column either share a common `+` or `-` connection. This means you can only turn on or off ROWS or COLUMNS at a time, and you control which elements in that row or column are lit by individual bits inside of the byte you send to the shift register.
This is why the matrix code only uses 8 bytes to control 64 individual pixels.
```arduino
uint8_t matrixcolumns[8] = {0};
int pokeMatrixPixel(uint8_t x, uint8_t y)
{
if (x > 7 || y > 7) {
ERROR(ERRNO_OUTOFBOUNDS);
}
// Because of the alignment of the screen in our demo, we treat columns as the X axis
matrixcolumns[x] = matrixcolumns[x] | (1 << y);
return ERRNO_SUCCESS;
}
```
We are actually storing 8 columns represented by 8 bytes. Each bit inside of those bytes represents a single pixel in the column (row id). We treat columns as X and rows as Y because of the orientation of the matrix on the breadboard relative to the player and other controls. When we are poking a pixel, we just turn on the bit inside of the column (X) for the row (Y). Then, when we go to draw pixels on the matrix (think like flipping a page buffer on a VGA display or populating a framebuffer), we just send each entire column one by one, using the row shift register as a selector:
```arduino
void displayMatrix()
{
for (int i = 0; i < 8; i++) {
writeLEDMatrix(matrixcolumns[i], ~(1 << i));
}
}
```
* Treat it like a tiny framebuffer. 64 (8*8) bytes, each containing a packed bitfield.
* High nibble
* On / Off
* Is it a pellet or a snake part
* Low nibble
* How many game cycles should this stay lit. For snake parts, this is a counter. For pellets this is ignored.
## Fun within constraints
I had never written a snake game before. (Wild, right?) So this was a fun project but it was also complicated by the fact that not only was I figuring out all the ins and outs of the 7 segment displays, the LED matrix, and the shift registers, but I was also figuring out how a snake game works. And I was doing it on a device that is (by modern standards) tiny - the ESP32-S3 has 512kB of internal RAM, with about 340kB of that being usable by your application. Granted, you can do A LOT with 512kB - the entire Amiga OS Kernel was written in 512kB of ROM, for example. But for average developers like us, that are used to working with gigabytes of ram at our disposal, this is essentially a starvation diet.
Further, the hardware components themselves introduced a ton of constraints on the project:
* The screen has only 1 color
* The screen is tiny. Only 64 possible pixels.
* I only have four 7 segment readouts to display information to the player
* The speaker can only make 1 sound
* The use of breadboards instead of protoboards required that all my D-Pad buttons were on the same horizontal plane
* Only 1 color
* Only 64 possible pixels
* Four 7 segment readouts
* Only 1 sound
* Only 1 button
* One player
* 1 difficulty adjuster, which controls the speed of the game clock
... But the product was (for me, at least) still really fun. Part of the reason this took me so long is that, when diagnosing a bug, I would just sit and play with it for long periods of time. Before I had game logic working, I would just watch the snae scroll off the side of the matrix and back on to the other side. I would play with the potentiometer and watch numbers change. Once I had the basic game logic working, and was diagnosing an issue with the scoreboard not displaying properly, I would just play snake for several rounds before getting back to work.
It is very, very rare that I feel any kind of *fun* in the work I do professionally. I'm usually *glad that it's done* more than I am *excited to see it working*. This was absolutely not that way. Working through it was very frustrating at times, and there was more than once that I thought "This is too ambitious. Step back and do something smaller." But I'm glad I didn't. Because every breakthrough was a delightful little reward.
And being able to play a hardware Snake game with my wife and kids, on a tiny little microcontroller, on a bunch of breadboards, that I (mostly) designed and implemented myself, was really fun. 10/10, highly recommended.
* 1 difficulty adjuster (controls the speed of the game clock)

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 808 KiB

View File

@@ -11,6 +11,7 @@ int pokeMatrixPixel(uint8_t x, uint8_t y)
if (x > 7 || y > 7) {
ERROR(ERRNO_OUTOFBOUNDS);
}
//Serial.printf("Poking (%d, %d)\n", x, y);
// Because of the alignment of the screen in our demo, we treat columns as the X axis
matrixcolumns[x] = matrixcolumns[x] | (1 << y);
return ERRNO_SUCCESS;