This is only a preview of the April 2026 issue of Practical Electronics. You can view 0 of the 80 pages in the full issue. Articles in this series:
Articles in this series:
Articles in this series:
Articles in this series:
Articles in this series:
|
Max’s Cool Beans
By Max the Magnificent
Weird & Wonderful
Arduino Projects
Part 16: etching sketches and snake shenanigans
H
i there, I hope you are feeling
anticipendulous and exhilarocious regarding the cunning
code we are about to create concerning our retrodocious games console.
Why yes, I have indeed delighted in
both of the Wicked movies. How did
you guess?
As you may recall, my retro gaming
console features a laser-cut front panel
mounted in a 3D-printed enclosure.
From the start, I noted that readers
without access to these tools could
construct their own consoles by simply
attaching the various parts, such as the
7-segment display modules, 14 × 10
tricolour light-emitting diode (LED)
array, and push-button switches, to a
sheet of timber.
I just heard from a reader we’ll call
Ian (because that’s his name). Ian was
keen to tell me about the current state
of his console. He enclosed some snapshots of his handiwork (Photos 1 and 2).
In his message, Ian commented:
As you can see, I don’t have access to
a CNC machine or 3D printer, so I had
to resort to my other passion: woodworking. My console is formed from
MDF mounted on a couple of pieces
of wood, but it seems to be working
out OK so far.
Last month, I decided to deviate
slightly from your design and build
my console with an onboard Nano
instead of separate cartridges. I have
an internal extension cable, so I can
plug a USB cable into the console to
download games as required.
I, for one, think Ian’s ingenious implementation has turned out brilliantly. This is just what I was envisaging.
Hopefully, this will prompt other readers who have been sitting on the “pity
me because I don’t have a CNC” fence
to leap into action and join in the fun.
Before we proceed, we should perhaps note that, in Ian’s realisation, the
40
four black north (N), south (S), east (E),
and west (W) direction control buttons
are located on the lower left-hand side
of his console. (These buttons are white
in my implementation.) Meanwhile,
the four red (R), yellow (Y), green (G),
and blue (B) action control buttons are
located on the lower right-hand side
of Ian’s panel.
This was the way I’d initially planned
to do things myself, and Ian was simply
following my earlier instructions. However, due to an unexpected turn of
events (I messed things up), I ended
up with my action buttons on the left
and my direction buttons on the right.
Oh well, what can you do, eh?
Ooh, shiny!
Continuing our gaming console saga,
in our previous column, we created
a “framework program” that we can
think of as the scaffolding that does
all the heavy lifting behind the scenes.
To ensure we’re all tap-dancing to
the same skirl of the bagpipes, you can
revisit this program in the file named
CB-apr26-code-01.txt. As usual, all files
mentioned in this column are available as part of the April 2026 download
package from the PE website at https://
pemag.au/link/acau
We subsequently used this framework as the basis for a simple program
that let us use our RYGB action control buttons to modify the colour of a
flashing pixel on our 14 × 10 array.
Next, we augmented this program to
use the four NSEW direction control
buttons to move the flashing ‘pixel’
around the array.
Finally, we taught the console to
keep track of how many times we press
any of the action control buttons and
display this value in the three leftmost
7-segment digits located at the top of
the console. Similarly, for the number
of times we tap any of the direction
control buttons, which we display in
the three rightmost 7-segment digits.
If your controls are arranged à la
Ian, you might decide to swap which
7-segment digits display the various
values. You can peruse and ponder
the way we left this program in the file
named CB-apr26-code-02.txt.
Electronic Etch A Sketch
The Etch A Sketch was invented in
1959 by a French electrician called
André Cassagnes. By the late 1960s
and early 1970s, this toy was a staple
Christmas stocking-stuffer in the UK.
I think it’s fair to say that almost every
kid I knew had one.
The Etch A Sketch is one of those
rare toys that never really went away.
Over the decades, it smoothly transitioned into ‘timeless toy’ territory,
eventually repositioning itself as a
retro classic that’s still manufactured
and sold to this day.
I know what you’re thinking. Did he
fire six shots or only five? Sorry, wrong
script. You are thinking to yourself:
“Why don’t we use our retro games
console to implement an electronic
Etch A Sketch?” I was just thinking the
same thing. “Great minds think alike”,
as they say. Of course, they also say,
“Fools seldom differ”, but that certainly doesn’t apply to us!
So, what will it take to convert our
existing program into the first pass
at a rudimentary electronic Etch A
Sketch? Surprisingly little. In fact, all
we need to do is tweak a single line of
code. Consider the UpdateLocation( )
function in the current version of our
program, as illustrated in Listing 2(a).
Remember, we’re using the convention that the listing number (2 in this
case) corresponds to the numerical
part of the matching code file name
(“02” here).
This function is called whenever
Practical Electronics | April | 2026
Photos
1 and 2:
Ian’s MDFbased
console.
we press one of our NSEW direction
control buttons. The first thing we do
on line 461 is set the current pixel
to the background colour (currently
BLACK). On lines 462 and 463, we
update our X-Y coordinates to reflect
their new values.
Then, on line 464, we call our
UpdateFlash() function to force the pixel
at our new X-Y location to start flashing with the current foreground colour.
Can you spot which line we need
to change? You’re right, it’s line 461.
Instead of setting the original pixel
to the background colour, we set it to
the foreground colour. I’ve just made
this change (in the file named CBapr26-code-03.txt), as illustrated in
Listing 3(a). Now, as we press our direction control buttons, we leave a trail
of glowing pixels behind us.
Any colour you want
The famous phrase, “You can have
any colour you want, as long as it’s
black”, is attributed to Henry Ford
regarding his revolutionary Model T
cars. This reflected his focus on mass
production with a single, fast-drying
black paint to maximise efficiency on
the assembly line, thereby making cars
affordable and widely available, alPractical Electronics | April | 2026
though the exact wording (and timing)
of the quote is debated.
Paradoxically, the only new colour
we want for our Etch A Sketch program is black, but there’s no way for
us to achieve it… or is there?
The problem with the updated version of our code is that we cannot prevent ourselves from leaving a trail of illuminated pixels as we manoeuvre our
459
460
461
462
463
464
465
466
467
468
469
flashing cursor around the array. This
is unfortunate because sometimes we
may wish to move the cursor without
marking the display as we go.
My knee-jerk reaction when I first
considered this conundrum was to
think, “I wish I had decided to include
an additional black (K) action button
located in the centre of my existing
RYGB action buttons.”
void UpdateLocation(int8_t deltaX, int8_t deltaY)
{
DrawPixel(X, Y, BackGndColor);
X = (X + NUM_COLS + deltaX) % NUM_COLS;
Y = (Y + NUM_ROWS + deltaY) % NUM_ROWS;
UpdateFlash(FORCED);
DirectionCtr = UpdateCounter(DirectionCtr, 1, MIN_COUNT, MAX_COUNT);
NumberToBuffer(DirectionCtr, 3, 0);
BufferToDisplay();
}
Listing 2(a): our original UpdateLocation() function.
459
460
461
462
463
464
465
466
467
468
469
void UpdateLocation(int8_t deltaX, int8_t deltaY)
{
DrawPixel(X, Y, ForeGndColor);
X = (X + NUM_COLS + deltaX) % NUM_COLS;
Y = (Y + NUM_ROWS + deltaY) % NUM_ROWS;
UpdateFlash(FORCED);
DirectionCtr = UpdateCounter(DirectionCtr, 1, MIN_COUNT, MAX_COUNT);
NumberToBuffer(DirectionCtr, 3, 0);
BufferToDisplay();
}
Listing 3(a): our modified UpdateLocation() function.
41
Of course, doing so would have introduced its own problems, not least
the lack of available digital input/
output (I/O) pins. The Arduino Uno
has 14 such pins. Pins 0 and 1 are
off the table because they are used to
communicate with the host PC, leaving us with 12.
We are using one pin to drive our
14 × 10 array and three pins to drive
the chain of 74HC595 8-bit serial-in,
parallel-out (SIPO) shift registers that
live in our 7-segment display modules,
leaving us with eight pins to access our
eight existing switches. This means
that, using our current scheme, there
would be no pins remaining to connect to any additional buttons.
Load B
Clock B
Shift over!
Fig.1: using PISO shift registers to support more switches.
One solution would be to add two
or more 74HC597 8-bit parallel-in, serial-out (PISO) shift registers. As we
see from their data sheet (www.ti.com/
lit/ds/symlink/cd54hc597.pdf), these
behave in a complementary manner
to their 595 cousins. We can load the
values from eight switches into a 597
simultaneously (in parallel), then clock
these values out serially (one after the
other) on a single data line.
As with the 595s, it’s possible to
daisy-chain multiple 597s together.
This means we can effectively add as
many switches as we wish while using
only three of our Arduino’s pins. For
example, with six 597s, we could support up to 48 switches (see Fig.1).
This is tempting for several reasons, but I’ve made the decision to
make do with the eight switches we
have. The reason for this will become
clear shortly.
Teaching old buttons new tricks
In our current code, whenever we
press one of our RYGB action buttons,
our switch-handling routine calls the
UpdateColor() function, passing it the
colour associated with that button.
The existing version of this function
is shown in Listing 3(b).
On line 448, we test whether the current foreground colour differs from the
colour we’ve just passed in. If so, on
line 450, we set the foreground colour
to be the new colour. Then on line
451, we call our UpdateFlash() function to force the pixel to start flashing
with the new foreground colour (ie,
the one associated with the button we
just pressed).
Observe that we don’t mess with the
background colour because, thus far,
we’ve assumed that it’s always set to
BLACK. Well, that’s about to change.
I’ve just modified this function, as illustrated in Listing 4(a) from the file
named CB-apr26-code-04.txt.
42
Data In
Load A
Clock A
Module 2
Digit 5
Digit 4
Module 1
Digit 3
Digit 2
Module 0
Digit 1
Digit 0
595
595
595
595
From
MCU
595
To
MCU
From Switches
597
446
447
448
449
450
451
452
453
454
455
456
457
595
597
597
597
597
597
Data Out
void UpdateColor(uint32_t color)
{
if (ForeGndColor != color)
{
ForeGndColor = color;
UpdateFlash(FORCED);
}
ActionCtr = UpdateCounter(ActionCtr, 1, MIN_COUNT, MAX_COUNT);
NumberToBuffer(ActionCtr, 3, 3);
BufferToDisplay();
}
Listing 3(b): our original UpdateColor() function.
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
void UpdateColor(uint32_t color)
{
if (ForeGndColor == color)
{
ForeGndColor = BLACK;
BackGndColor = WHITE;
}
else
{
ForeGndColor = color;
BackGndColor = BLACK;
}
UpdateFlash(FORCED);
ActionCtr = UpdateCounter(ActionCtr, 1, MIN_COUNT, MAX_COUNT);
NumberToBuffer(ActionCtr, 3, 3);
BufferToDisplay();
}
Listing 4(a): our modified UpdateColor() function.
As you can see, I’ve modified the
test on line 448. In this new version, we check whether the current
foreground colour is the same as the
colour we’ve just passed in. If so
(for example, the foreground colour
is RED and we just pressed the R
button), then we set the foreground
colour to BLACK and the background
colour to WHITE.
Otherwise, we set the foreground
colour to the colour of the button we
just pressed, and we reset the background colour to BLACK.
In effect, we’ve made our four action
buttons context-sensitive. Pressing
any of these buttons once selects that
button’s colour; pressing it again toggles us into erase mode; and pressing any action button while we’re
in erase mode returns us to that button’s colour.
One more thing
As a final tweak, when we created
our original flashing pixel program
that evolved into this month’s Etch
A Sketch program, we could only
display the four RYGB colours. This
meant that we were obliged to select
one of these colours as our default start
colour. We picked green because that’s
my favourite.
However, now that we can display
black as a colour, it makes sense to set
our default foreground and background
colours to BLACK and WHITE, respectively (see the file named CB-apr26code-05.txt).
This means that we can move our
Practical Electronics | April | 2026
flashing white pixel around the array
without leaving a coloured trail until
we reach the location from which we
wish to commence our drawing. While
this is only a minor modification in the
scheme of things, I find it both aesthetically and logically pleasing.
Although our Etch A Sketch implementation is simple, it’s surprisingly
enjoyable to play with. Kids of all ages
will love it (I know I do)!
In fact, I was just thinking that
we could all have a little competition to see who can create the best
image using the current incarnation
of our code. If you wish to email your
entry to me, I may include some of
the more interesting offerings in a
future column.
Start
SR = 0xFFFF
SR = 0xFFFF
State = WFAZ
SR ==
0x0000?
WFAZ = WAITING_FOR_ALL_ZEROS
JGA = JUST_GONE_ACTIVE
WFNAZ = WAITING_FOR_NOT_ALL_ZEROS
WFAO = WAITING_FOR_ALL_ONES
JGI = JUST_GONE_INACTIVE
State = WFAZ
Read Switch
No
States
Start
Performed
by setup()
function
Functions
UpdateSwitches()
ProcessSwitches()
Read Switch
No
SR ==
0x0000?
Yes
SR = Shift Register
RC = Repeat Counter
GRA = Game-Related Action
Yes
RC = Start Val
Additional state and
logic to implement
auto-repeat function
State = JGA
State = JGA
Optional GRAs
Optional GRAs
State = WFAO
State = WFAO
State = WFNAZ
Read Switch
Read Switch
Read Switch
Thanks for the memory
Before we proceed, it’s worth noting
that our original flashing pixel program
consumed 4430 bytes (~13%) of flash
and 126 bytes (~6%) of RAM. How
much more memory do you think we’ve
consumed by transforming it into our
Etch A Sketch program?
The answer is surprisingly little. Due
to the way we architected things, we
required only small modifications to
a couple of core functions. As a result,
we added only 78 more bytes of flash
consumption and 4 more bytes of RAM,
resulting in a usage of 4508 bytes (still
~13%) of flash and 130 bytes (still
~6%) of RAM.
Arrgggh (sincerely)
I have a confession to make: I’ve
made a mistake. I know, I know… I
find this as hard to write as you do
to read, but even awesome Albert
(my uncle, not the other one) had
his off days.
In the past, we’ve noted that when
we instantiate our array of NeoPixels,
the system reserves 24 bits of memory
for each array element. It uses these
to store the three 8-bit RGB values associated with each pixel. In our case,
since we are working with a 14 × 10
array, this amounts to 3 × 140 = 420
bytes of memory.
What we’ve neglected to consider
is where these memory locations live.
While they reside in RAM, the problem is that, because the NeoPixel library allocates this memory dynamically (don’t ask), it is invisible to the
Arduino’s integrated development environment (IDE), which is why we’ve
failed to account for it.
The bottom line is that although the
IDE reports we’re using only 130 bytes
of RAM, we’re actually using 130 +
420 = 550, which is ~27% of the 2048
bytes (2KiB) available to us. This isn’t
as innocuous as we’d thought, but it’s
Practical Electronics | April | 2026
No
SR ==
0xFFFF?
No
SR ==
0xFFFF?
No
SR ==
0x0000?
Yes
Yes
Yes
State = JGI
State = JGI
RC = RC - 1
Optional GRAs
Optional GRAs
RC == 0?
No
Yes
Standard
operation 'flowchart'
(b) Augmented
with are
withactive-low).
auto-repeat capability
Fig.2:(a)our
switch-handling
(all switches
still not worrisome enough to warrant
us wearing our frowny faces.
Idly pondering
For reasons I no longer recall, I was
idly pondering the possibility of us
adding ‘auto-repeat’ functionality to
our buttons. For example, rather than
pressing the east (E) button multiple
times, I was wondering if it would be
advantageous to allow pressing and
holding the button to automatically
repeat the action.
Before we plunge headfirst into the
mire, it’s worth taking a few moments
to remind ourselves how we are currently debouncing and processing our
switches. Each switch is associated
with a simple state machine, as illustrated in Fig.2(a).
Fig.2 shows state machines presented
as pseudo-flowcharts rather than traditional state diagrams or program execution flowcharts (that’s just the way
they fell out when I was drawing them).
This means we can’t directly translate
these diagrams into code; they’re just
a way of visualising what’s going on.
Each major operating state is shown
as a purple rectangle. White diamond
shapes represent transition decisions,
while white rectangles reflect actions
to be executed.
In our current code, as shown in
Fig.2(a), we decided that our state
machine should have four states:
WAITING_FOR_ALL_ZEROS (ie, waiting for the switch to become fully/
stably active), JUST_GONE_ACTIVE,
WAITING_FOR_ALL_ONES (waiting
for the switch to become fully/stably
inactive), and JUST_GONE_INACTIVE.
We can shorten these names to WFAZ,
JGA, WFAO and JGI, respectively.
Remember that a copy of the state
machine shown in Fig.2(a) is applied
to each switch. The setup( ) function
preloads the shift register (SR) associated with each switch with 0xFFFF
and sets its state to WFAZ, reflecting
the assumption that all our switches
commence in their inactive states.
If you look at our loop( ) function,
you’ll see that once every millisecond, it first calls the UpdateSwitches()
function, followed by the Process
Switches() function.
The UpdateSwitches() function handles noise rejection and switch debouncing. It’s also responsible for transitioning the states from WFAZ to JGA
and from WFAO to JGI. Meanwhile, the
43
ProcessSwitches() function performs
any game-related actions (GRAs) associated with the switch being pressed
or released.
Once these actions have been performed, this function is also responsible for transitioning the states from
JGA to WFAO and from JGI to WFAZ.
When you come to a fork…
Yogi Berra (1925-2015) was an iconic
American baseball player—catcher,
manager, and beloved cultural figure.
He was famous for his paradoxical and
humorous “Yogi-isms”, memorable
sayings that appeared nonsensical at
first, but often made profound sense
the more you thought about them, such
as “It’s tough to make predictions, especially about the future”.
One of my favourites is, “When you
come to a fork in the road, take it”. As
with many of Yogi’s Zen-like offerings,
there’s more to this than meets the eye.
The most common reading is: don’t
overthink decisions; just choose a path
and move forward. However, there is
a backstory here.
When approaching Yogi’s house, the
road split into two branches, but both
forks eventually led to the same destination, so it didn’t actually matter
which you chose. I tell you, you won’t
find nuggets of knowledge and tidbits
of trivia like this in other, lesser electronics magazines!
The reason I mention this here is
that, like a thief in a cutlery store, I
feel the urge to take a fork. That’s not
something I expected to hear myself
say when I woke up this morning.
To be slightly more specific, in software development, a ‘fork’ is an independent copy of an existing codebase
created to explore or evaluate changes
without affecting the main line of development. That’s what we’re going
to do here.
Can you repeat that?
I don’t want to incorporate autorepeat into our main codebase at this
time, so I’ve decided to take a fork. Our
starting point will be our original flashing pixel program (CB-apr26-code-02.
txt), which uses the RYGB action buttons to modify the colour of a flashing
pixel and the NSEW direction buttons
to move it around our 14 × 10 array.
I’ve just forked this code into a test
program named CB-apr26-code-06.txt.
In our original sketch, as depicted
in Fig.2(a), each switch uses a simple
four-state debounce state machine that
generates one action-triggering event
per press. This event occurs when the
input becomes stably active and the machine enters its JUST_GONE_ACTIVE
state. In the case of the action buttons,
44
the event changes the flashing pixel’s
colour, while the direction buttons
modify its location.
To add auto-repeat functionality to
the direction buttons, we augment the
per-switch state machines, as shown in
Fig.2(b). First, we extended the state
machine by adding a repeat counter,
rptCtr (labelled RC in the diagram), to
the SwitchStuff structure.
We also need to introduce a new
WAITING_FOR_NOT_ALL_ZEROS (aka
WFNAZ) state, which we can think of
as ‘waiting for the switch to stop being
active and commence its transition to
becoming stably inactive’. We’ll use
this state to distinguish between the
button being held down and it being
released.
The only change to the Update
Switches( ) function is that, once a
switch becomes stably active, the code
sets rptCtr to its initial delay value. We
then enter the JUST_GONE_ACTIVE
state, as before.
When the ProcessSwitches() function sees the JUST_GONE_ACTIVE
state, it first performs any game-related
actions (GRAs) associated with that
switch. For the action switches, which
don’t require auto-repeat (at least,
not in this instance), the state machine continues to transition to the
WAITING_FOR_ALL_ONES state, as
shown in Fig.2(a).
By contrast, for the direction switches, it transitions to our new WAITING_
FOR_NOT_ALL_ZEROS state. While
the state machine remains in that state
and the button is still pressed, the code
decrements rptCtr on each tick. When
the counter expires (reaches zero), the
code reloads rptCtr and generates another JUST_GONE_ACTIVE event, resulting in repeated movement in the
selected direction.
As soon as the button is released,
the state machine transitions to
WAITING_FOR_ALL_ONES, after which
it proceeds exactly as before.
I just ran a quick test, and this works
rather well. Pressing any of the action
buttons changes the flashing pixel’s
colour as before (pressing and holding the action buttons has no effect
because we haven’t modified their
state machines). Pressing and releasing any of the direction buttons moves
the flashing pixel one step in that direction, while pressing and holding
them keeps it moving until the button
is released.
As I noted earlier, I’m not sure if
we will ever need this functionality,
but now we know we can do it if we
want to.
puter game that emerged in the 1970s
and has been continuously reinvented
ever since. It belongs to a small, illustrious club of classic games, alongside
titles like Pong and Tetris, that prove
you don’t need fancy graphics or complex rules to create something engaging.
Snake’s inclusion on Nokia mobile
phones in the late 1990s made it one
of the most widely played games in
history. The rules are instantly understandable (move, eat, grow, don’t crash),
yet the gameplay remains compelling.
Snake still appears today in browsers,
embedded systems demos, coding tutorials, and even as Easter eggs.
At its heart, Snake is beautifully
simple: a series of connected segments
move across a grid, one step at a time,
changing direction under the user’s
control, growing longer as the game
progresses and demanding ever more
careful control.
This simplicity makes it perfect for
newbie programmers because every part
of the game—position, direction, movement, and state—cleanly maps onto
fundamental programming concepts.
In our case, we’ll bring this timeless
concept to life on the 14 × 10 array of
tricolour LED ‘pixels’ on our retro game
console. We’ll take this step-by-step
(or slither-by-slither), starting by creating a short, four-segment snake at a
known location. Later, we’ll learn how
to make it move, turn, and eventually
(in later stages) hunt for food and grow.
But first, we need to set the scene…
Circular thinking
Let’s suppose that, at some time in the
future, we end up with an 11-segment
snake, as illustrated in Fig.3. One segment will act as the head (we’ll colour
this purple), with the remaining 10 segments acting as the body (we’ll colour
them green).
From Fig.3, we can deduce that,
over the past couple of moves, the
snake has been heading east. There
are only three possible options for its
next move: to carry on moving east, or
to change direction and head north or
south. It can’t move west because that
would mean moving back into itself.
Although the representation in Fig.3
appears simple, it hides underlying
Tail
Head
Timeless classics
Snake is an arcade and early com-
Fig.3: an 11-segment snake.
Practical Electronics | April | 2026
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
#define TICK
#define SNAKE_MOVE_TICKS
1
500
#define NUM_SEGMENTS
#define START_HEAD
#define START_TAIL
#define START_LENGTH
#define START_LEVEL
140
3
0
4
1
#define NUM_DIRECTIONS
#define NORTH
#define SOUTH
#define EAST
#define WEST
4
0
1
2
3
#define HEAD_COLOR
#define BODY_COLOR
MAGENTA
GREEN
typedef struct
{
uint8_t x;
uint8_t y;
} SnakeSegment;
SnakeSegment Snake[NUM_SEGMENTS];
int16_t SnakeHead;
int16_t SnakeTail;
int16_t SnakeLength;
uint8_t SnakeDirection;
uint8_t GameLevel = START_LEVEL;
uint32_t TimeThen = micros();
uint32_t TimeNow;
uint32_t SnakeThen = TimeThen;
Listing 7(a): snake-related stuff.
layers of complexity. For example, although we think of our ‘play area’ as a
2D array, the pixels (LEDs) themselves
are wired as a single long serial string.
This means that snake segments that
occupy adjacent squares on the grid
might be dozens of pixels apart in the
underlying 1D string.
Things become more complicated
when we want the snake to advance.
Fortunately, we don’t have to redraw
every segment. In the absence of any
“food” (the “eating” of which will make
the snake grow longer), moving the
snake simply involves placing the head
segment in its new position, changing
the old head segment into a body segment, and deleting the tail segment.
On the downside, using a 2D array
that ultimately maps onto a 1D string
to track the segment locations can be a
pain. The solution is to use a circular
buffer. As a worst-case scenario, our
snake can grow so large that it fills the
grid, so we will create a circular buffer
of 14 × 10 = 140 elements, numbered
from 0 to 139, each capable of storing the X-Y location of one of the
snake’s segments.
Let’s imagine the 11-segment snake
from Fig.3 being stored somewhere
in a 140-element circular buffer, as
depicted in Fig.4. Once we’ve generated the initial snake, stored it (in
the circular buffer), and drawn it (on
the array), all we need to do is keep
track of the locations of the head and
tail in the circular buffer.
These can be represented as integers (I think of them as pointers),
which we use to index into the buffer.
Now, when the snake advances, in
addition to updating the colours of
any related pixels on the array, all
we need to do is increment the head
and tail pointers into the buffer. The
reason we call this a circular buffer
is that we manipulate it so that its
end ‘wraps around’ to its beginning.
We will also keep track of the
snake’s length, which will eventually form the score in our game. The
longer the snake, the higher the score.
When you think about it, if we
know the value of the head pointer
and the length of the snake, then we
can easily generate the value of the
tail pointer on-the-fly whenever we feel
the need. Personally, however, I prefer
to maintain both head and tail pointers
(it’s my ‘programmer’s prerogative’).
Hello, snake!
Traditionally, a programmer’s first
tentative step when learning a new language or working with a new system
is to coax the computer to print some
variant of “Hello, world!” (the original
C Programming Language book used
“hello, world” without capitalisation
or an exclamation mark).
The underlying idea is both modest
and powerful: to prove that the toolchain works, confirm that text can be
produced, and celebrate the smallest
possible success before attempting
anything more ambitious.
We are going to do something similar
with our Snake program. We’ll start by
generating a four-element snake, storing it in our circular buffer, and drawing it on our array.
I just copied our fundamental frame-
Tail
Head
Length = 11
x x
y y
x
y
Fig.4: a 140-element circular buffer.
Practical Electronics | April | 2026
x
y
x
y
x
y
x
y
x
y
x x
y y
139
x x
y y
137
138
x
y
n+9
n + 10
x
y
n+7
n+8
x
y
n
n+1
n+2
n+3
n+4
n+5
n+6
x
y
0
1
2
x
y
work (named CB-apr26-code-01.txt)
and made a few tweaks to implement
a “Hello, snake!” program (in the file
named CB-apr26-code-07.txt).
Our first task is to add game-related
definitions and declarations, as illustrated in Listing 7(a). We still have our
1ms TICK definition on line 138, to
which we add a 500ms (half-second)
SNAKE_MOVE_TICKS definition on
line 139 (we won’t be using this in
this program, but it’s there for when
we need it).
On lines 141 through 145, we define
the number of elements in our circular buffer (140), the values of the
snake’s head and tail pointers into
the buffer (3 and 0), the length of the
snake (4 segments) and the game’s
starting level (1).
On lines 147 through 151, we define
the number of directions the snake can
move (4) and associate values of 0, 1,
2, and 3 with directions north, south,
east and west, respectively.
On lines 153 and 154, we define the
colours associated with the snake’s
head and body segments.
On lines 156 through 160, we define
a structure to store the X-Y coordinates associated with an individual
snake segment. Then, on line 162,
we instantiate our circular buffer as a
140-element array of these structures.
Finally, on lines 164 through 172,
we declare the variables that hold the
snake’s current state—head and tail indices (pointers) into the circular buffer,
the snake’s length and direction, and
the current game level—along with a
couple of timing variables we’ll use
to schedule events like snake motion
without resorting to blocking delays.
If you look at the setup() function in
CB-apr26-code-07.txt, you’ll see that
we’ve added calls to two new functions at the end, just after the calls to
our power-on self-test functions. The
first, InitializeSnake( ), generates our
4-element snake, stores it in our circular buffer, and draws it on our array.
For simplicity, we’re starting with a
horizontal snake pointing east (Fig.5).
The second, InitializeDisplays(), sets
up our 7-segment displays, presenting
“L” (for level) in the most significant
digit (digit 5), the current game level
(1) in digit 4, and the current length
of the snake (4) in the three least significant digits (2, 1, and 0).
Fig.5: hello there, snake!
45
I just ran this program, and it looks
very… static, but at least we’ve laid
the groundwork for what’s to come.
A simple slither
Now, let’s make our snake a little
more animated. We’ll start with a
simple ‘slither’ by just making it move
eastward. Furthermore, when the snake
reaches the right-hand side of the array,
it will ‘wrap around’ and reappear on
the left-hand side.
First, in the file CB-apr26-code-08.
txt, we modify our loop() function, as
seen in Listing 8(a). The only change
here appears on line 217, where we
call a new UpdateSnake() routine. It’s
worth noting that we are still calling
the functions to debounce and process
our switches (lines 218 and 219, respectively); it’s just that we aren’t actually doing anything with them yet,
but we will…
Next, we add the new UpdateSnake()
routine itself, as illustrated in Listing
8(b). One thing you’ll notice is that the
controlling construct on line 474 is
very similar to the one we used in the
UpdateFlash( ) function in our Flashing Pixel and Etch A Sketch programs.
The way this is currently set up,
with SNAKE_MOVE_TICKS defined
as 500ms, is that the snake will move
every half-second. One thing we might
do in the future is to gradually decrease
this value, thereby speeding up the
snake and increasing the difficulty in
controlling it as we progress through
the game’s levels.
On line 476, we determine the location of the new head pointer for use
with our circular buffer. We’ve seen
this use of the modulo (%) operator
before. In simple terms, this modulo
operation makes the buffer behave as
if its ends are joined together.
210
211
212
213
214
215
216
217
218
219
220
221
222
223
Each time we advance the head
pointer, it increments by one. When it
reaches the last element, the modulo
calculation quietly wraps it around to
zero so we can continue from the start
without any special-case code.
In the next iteration of the code, we’re
going to allow the snake to move in any
direction. For the moment, however,
we’re assuming it always wants to move
east, which explains lines 478-486.
Observe the use of the modulo operators on lines 488 and 489. These
allow the snake to wrap around the
array in any direction, east to west
and north to south. The reason we’ve
added + NUM_COLS in line 488 and +
NUM_ROWS in line 489 is to prevent
problems if deltaX is -1 or deltaY is -1,
respectively.
I’ve just run this incarnation of the
program, and it’s a wonder to behold,
but it leaves me wanting more.
// Do this over and over again
void loop()
{
TimeNow = millis();
if ( (TimeNow - TimeThen) >= TICK)
{
UpdateSnake();
UpdateSwitches();
ProcessSwitches();
TimeThen = TimeNow;
}
}
Listing 8(a): tweaking the loop() function.
Cycle 0
Cycle 1
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
Put your snake on automatic
If I had a coconut for every time
I’ve heard someone say, “Put your
snake on automatic”, I wouldn’t have
many coconuts, but that’s not important here.
What we’re going to do now is allow
our snake to move randomly around
the array. First, however, let’s return
to the rationale for the order in which
we perform the actions of removing the
tail segment before moving the head
segment, as shown in Listing 8(b), lines
495-496 and 498-502, respectively.
There are two ways our snake could
move. It could stretch out its head and
then pull in its tail, or it could pull in
its tail and then stretch out its head.
We’ve chosen to implement the latter.
This reflects the behaviour of the classic Snake game, where the head can
move into the square the tail has just
vacated.
void UpdateSnake ()
{
int16_t tmpSnakeHead;
uint8_t tmpX;
uint8_t tmpY;
uint8_t deltaX;
uint8_t deltaY;
if ( (TimeNow - SnakeThen) >= SNAKE_MOVE_TICKS)
{
tmpSnakeHead = (SnakeHead + 1) % NUM_SEGMENTS;
// Generate new X-Y location for the head
// This part coming soon
// Let's asume the direction is east
if (SnakeDirection == EAST)
{
deltaX = 1;
deltaY = 0;
}
tmpX = (Snake[SnakeHead].x + NUM_COLS + deltaX) % NUM_COLS;
tmpY = (Snake[SnakeHead].y + NUM_ROWS + deltaY) % NUM_ROWS;
// Remove tail segment
DrawPixel(Snake[SnakeTail].x, Snake[SnakeTail].y, BLACK);
SnakeTail = (SnakeTail + NUM_SEGMENTS + 1) % NUM_SEGMENTS;
// Set old head segment to body color
DrawPixel(Snake[SnakeHead].x, Snake[SnakeHead].y, BODY_COLOR);
// Move head and set it to head color
SnakeHead = tmpSnakeHead;
Snake[SnakeHead].x = tmpX;
Snake[SnakeHead].y = tmpY;
DrawPixel(Snake[SnakeHead].x, Snake[SnakeHead].y, HEAD_COLOR);
Neos.show();
SnakeThen = millis();
}
}
Listing 8(b): implementing a simple eastward slither.
Cycle 2
Cycle 3
Cycle 4
Cycle 5
Fig.6: don’t eat my tail!
46
Practical Electronics | April | 2026
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
// Pick a new direction (can't be 180 degrees)
do {
tmpDirection = random(0, NUM_DIRECTIONS);
} while (tmpDirection == (SnakeDirection ^ 1));
SnakeDirection = tmpDirection;
// Generate new X-Y location for the head
if (SnakeDirection == NORTH)
{deltaX = 0; deltaY = 1;}
else if (SnakeDirection == SOUTH)
{deltaX = 0; deltaY = -1;}
else if (SnakeDirection == EAST)
{deltaX = 1; deltaY = 0;}
else if (SnakeDirection == WEST)
{deltaX = -1; deltaY = 0;}
www.poscope.com/epe
tmpX = (Snake[SnakeHead].x + NUM_COLS + deltaX) % NUM_COLS;
tmpY = (Snake[SnakeHead].y + NUM_ROWS + deltaY) % NUM_ROWS;
Listing 9(a): moving in a random direction.
By removing the tail first, we avoid
falsely detecting a collision with the
snake’s own body and allow the familiar smooth, continuous motion players expect. It’s only when the snake
eats food (which we’ll implement next
month) that we skip the tail-removal
step, thereby causing the snake to grow
by one segment.
All this is depicted in Fig.6. Our
snake starts by progressing eastward
(Cycle 0). Then it turns south (Cycle 1),
then west (Cycle 2), then north (Cycle
3). This is the key point in the snake’s
journey. If we didn’t remove the tail
segment before drawing the new head
segment, our snake would effectively
try to eat its own tail, which would be
a bad thing to happen (ask a real snake
if you don’t believe me).
The only changes we need to make to
implement the snake’s random motion
in the file named CB-apr26-code-09.
txt are illustrated in Listing 9(a). The
first thing we do on lines 480-482 is
loop around, generating a new random
direction (0, 1, 2 or 3), corresponding to north, south, east and west, respectively.
The rule we need to implement
here is that the snake cannot ‘go back
on itself’. To put this another way, if
it’s heading north, it cannot decide
to head south (and vice versa). If it’s
Practical Electronics | April | 2026
heading east, it cannot choose to head
west (and vice versa).
The key observation is that opposite directions form pairs: north ↔
south (0 ↔ 1) and east ↔ west (2 ↔ 3).
This means each pair differs by only
the least significant bit.
Using the XOR operator (^) in the
expression SnakeDirection ^ 1 in the
conditional test on line 482 performs
a delightful bit of digital sleight-ofhand, flipping the least-significant bit
of the current direction to swap north
with south and east with west. If the
random choice matches this forbidden
opposite, we simply try again until a
legal direction appears.
Once we’ve determined the snake’s
new direction, we use lines 487-494 to
set up appropriate deltaX and deltaY
values, which we then use to generate
the new X-Y location of the snake’s
head.
I don’t know about you, but I feel a
little “Tra-la” is in order. As I pen these
words, my four-segment snake is happily roaming around its little universe.
Next time
Our next task will be to take control
of the snake’s path using our direction buttons. We’ll also start randomly
adding food, the eating of which will
make our snake grow longer.
We w i l l k e e p
adding games and
performing experiments with our console in the months
to come. At the same
time, we will be starting a new project
that’s so exciting I can
barely contain myself.
As always, if you
have any thoughts
you’d care to share,
please feel free to
drop me an email at
max<at>clivemaxfield.
PE
com
- USB
- Ethernet
- Web server
- Modbus
- CNC (Mach3/4)
- IO
- PWM
- Encoders
- LCD
- Analog inputs
- Compact PLC
- up to 256
- up to 32
microsteps
microsteps
- 50 V / 6 A
- 30 V / 2.5 A
- USB configuration
- Isolated
PoScope Mega1+
PoScope Mega50
- up to 50MS/s
- resolution up to 12bit
- Lowest power consumption
- Smallest and lightest
- 7 in 1: Oscilloscope, FFT, X/Y,
Recorder, Logic Analyzer, Protocol
decoder, Signal generator
47
|