[go: up one dir, main page]

DEV Community

Cover image for HWYDT: Snake Game
JoeStrout
JoeStrout

Posted on

HWYDT: Snake Game

One of the most classic video games is generically known as "Snake". In this game, you play a long snake that winds is way around a level, making only 90-degree turns, and growing longer every time you eat something (usually an apple). The game is over when you bite your own tail, and as you get longer, it gets harder and harder to avoid doing that.

Early versions of the game were done on low-resolution screens, or simply used plain squares for every segment of the snake. This made it very easy to draw the moving snake: you simply draw a new square for the new position of the head, and erase the last square to update the tail.

Single-player classic Snake game

However, modern players expect a little more panache. We'd like to see a head with features (such as eyes) that make it clear which way it's facing, and likewise for a tail. And ideally, it should have a smooth body in between, like a real snake.

How would you do that?

Level One: Round Segmented Body

We're going to tackle this job in two stages. For the initial, simpler version, we will draw a segmented snake, with round body parts that fit together in any orientation. This looks more like Centipede than Snake, but it's a good start.

_Centipede_ arcade game screen shot

Creating the Images

Fire up Mini Micro (on your local machine — you can't do this project in the web runner, since you need to be able to create and save images in addition to the code). Click on the top disk slot, and mount some folder where you want to store your project. Now enter

run "/sys/demo/fatbits"
Enter fullscreen mode Exit fullscreen mode

This runs the fatbits program, which is a demo but also a useful little image editor. Create a New Image called "body.png", 32x32 pixels in size; and then use the circle and fill tools to make a round body segment, with a 4-pixel empty margin all the way around, like this.

Screen shot of body segment in fatbits editor

Why the 4-pixel margin?
I often like to make my tiles a little bit bigger than they "need" to be, and then set up my TileDisplay with a corresponding amount of overlap. In this case, I'll set td.overlap = 8 (where td is my TileDisplay), but still with td.cellSize = 32. That gives me an effective cell spacing of 24 pixels, but it allows cell images to extend beyond this 24-pixel grid a bit, overlapping if necessary. You'll see this used on both the head and tail images, below. This trick breaks up the obvious square grid of the TileDisplay and makes the game look a little fancier than it really is.

Now click the little "+" tab at the top, and repeat the process to create "head.png", and then "tail.png".

Close-up of head image

Close-up of tail image

Press Q to quit when you're done. (Note that I made my images in white and gray so that they can be tinted at runtime, any color we like.)

What Kind of Display?

Now before we really get going with any code, we have to decide which of Mini Micro's several different types of display we're going to use. There are three reasonable options:

  1. PixelDisplay: we could use drawImage to draw each part of the snake to the screen, and fillRect to clear it.

  2. SpriteDisplay: we could create a sprite for each segment of the snake. This has the advantage that sprites can be easily rotated.

  3. TileDisplay: we could prepare a tile set with all the snake parts in all rotations, and then draw and erase by simply setting cells in the display to the appropriate tile index.

Any of these would work. If all we needed to do was draw a moving snake, the SpriteDisplay might be easiest. But to make a full game, we also have to think about collision detection. We need to know when our snake has hit its own tail, or an apple, or any other game hazards. This implies that somewhere, we'll need a big matrix keeping track of what's on the map. A TileDisplay already has that: for every cell of the display, we can get the current tile index just as easily as we can set it. So that leads me to option 3.

Making a Tile Set at Runtime

A TileDisplay makes use of a tile set: a big image that is divided into even rectangular sub-images called tiles. Each cell of the display draws any one of these tiles at any given time, selected by its index within the tile set.

Normally we think of the tile set as something prepared ahead of time in an art program, and just loaded. But it doesn't have to be; you can assemble it at runtime, and the "tileUtil" module (in /sys/lib) provides some utilities to make this easier.

We're going to do that here, because we drew our snake parts in just one orientation — facing the right. But we need them in all four orientations. So, start my making a little helper function that loads an image, and also rotates it by some (multiple of 90) degrees.

loadRotated = function(path, angle=0)
    img = file.loadImage(path)
    if img isa Image then
        img.rotate(angle)
        return img
    end if
    print "No such image: " + path
    exit
end function
Enter fullscreen mode Exit fullscreen mode

With that helper, we can write another function to actually make the tileset.

makeTileSet = function
    globals.empty = Image.create(32, 32, color.clear)
    globals.images = [empty]*13
    images[1] = loadRotated("head.png", 0)
    images[2] = loadRotated("head.png", 90)
    images[3] = loadRotated("head.png", 180)
    images[4] = loadRotated("head.png", 270)
    images[5] = loadRotated("body.png", 0)
    images[6] = loadRotated("body.png", 90)
    images[7] = loadRotated("body.png", 180)
    images[8] = loadRotated("body.png", 270)
    images[9] = loadRotated("tail.png", 0)
    images[10] = loadRotated("tail.png", 90)
    images[11] = loadRotated("tail.png", 180)
    images[12] = loadRotated("tail.png", 270)   
    return tileUtil.toTileSet(images)
end function
Enter fullscreen mode Exit fullscreen mode

Let's take a moment to understand what this does. First we create an empty image, 32x32 pixels, initialized to color.clear. Then we prepare a list called images, which initially just refers to that empty image 13 times. (Why 13? Because we need one slot for empty areas where there is no snake, and then 4 orientations each of our 3 body parts.) The rest of the function just stuffs the correct image into each slot, and then uses tileUtil.toTileSet to turn these into a single big image, suitable for use with a TileDisplay.

(I assigned to globals.empty and globals.images here just to aid with debugging; I found it useful to peek at these with the view command to make sure it was working. You could remove globals. to keep the global namespace clean if you prefer.)

Note that when you set up your tile set this way, it's easy to see which index to use on the TileDisplay. Looking for the tail image rotated 180°? From the code above, we can see that's index 11.

Moving and Growing

Once we have our TileDisplay set up with the tile set create above, the other key bit of the program is move, which moves the snake and optionally makes it grow. (To "grow" the snake simply means to advance the head while leaving the tail alone, thus making the snake 1 unit longer.)

move = function(nextDir, grow=false)
    // convert the old head into a body
    x = snakePos[0][0]; y = snakePos[0][1]
    td.setCell x, y, td.cell(x,y) + 4

    // place the new head   
    if nextDir == RIGHT then x += 1
    if nextDir == UP then y += 1
    if nextDir == LEFT then x -= 1
    if nextDir == DOWN then y -= 1
    td.setCell x, y, 1 + nextDir
    snakePos.insert 0, [x,y]

    // remember our new current direction
    globals.curDir = nextDir

    // if we want to grow the snake, then we're done
    if grow then return

    // remove the old tail
    td.setCell snakePos[-1][0], snakePos[-1][1], 0
    snakePos.pop

    // convert the new last segment into a tail
    x = snakePos[-1][0]; y = snakePos[-1][1]
    td.setCell x, y, td.cell(x,y) + 4   
end function
Enter fullscreen mode Exit fullscreen mode

This code takes advantage of the way we organized our snake parts in the tile set. For each of the four heads (i.e. one for each direction), we have four bodies — so we can convert a head to a body by simply adding 4 to the tile index. Similarly, if we add 4 again, we convert a body into a tail. Moreover, when it comes to adding a new head, we can simply use 1 + nextDir, where nextDir is a value from 0-3 indicating directions in the same order we used in makeTileSet.

The full program is a bit over 100 lines — twist it open below if you want to see it.

Full "Level 1" Snake Game
// Snake! (Non-bendy-body version)
import "tileUtil"

// define our directions
RIGHT = 0
UP = 1
LEFT = 2
DOWN = 3

loadRotated = function(path, angle=0)
    img = file.loadImage(path)
    if img isa Image then
        img.rotate(angle)
        return img
    end if
    print "No such image: " + path
    exit
end function

makeTileSet = function
    globals.empty = Image.create(32, 32, color.clear)
    globals.images = [empty]*13
    images[1] = loadRotated("head.png", 0)
    images[2] = loadRotated("head.png", 90)
    images[3] = loadRotated("head.png", 180)
    images[4] = loadRotated("head.png", 270)
    images[5] = loadRotated("body.png", 0)
    images[6] = loadRotated("body.png", 90)
    images[7] = loadRotated("body.png", 180)
    images[8] = loadRotated("body.png", 270)
    images[9] = loadRotated("tail.png", 0)
    images[10] = loadRotated("tail.png", 90)
    images[11] = loadRotated("tail.png", 180)
    images[12] = loadRotated("tail.png", 270)   
    return tileUtil.toTileSet(images)
end function

// display set-up
clear
display(4).mode = displayMode.tile
td = display(4)
td.cellSize = 32
td.overlap = 8
td.tileSet = makeTileSet
td.tileSetTileSize = 32
td.extent = [floor(960/24), floor(640/24)]
td.clear 0

// function to move the snake in any direction
move = function(nextDir, grow=false)
    // convert the old head into a body
    x = snakePos[0][0]; y = snakePos[0][1]
    td.setCell x, y, td.cell(x,y) + 4

    // place the new head   
    if nextDir == RIGHT then x += 1
    if nextDir == UP then y += 1
    if nextDir == LEFT then x -= 1
    if nextDir == DOWN then y -= 1
    td.setCell x, y, 1 + nextDir
    snakePos.insert 0, [x,y]

    // remember our new current direction
    globals.curDir = nextDir

    // if we want to grow the snake, then we're done
    if grow then return

    // remove the old tail
    td.setCell snakePos[-1][0], snakePos[-1][1], 0
    snakePos.pop

    // convert the new last segment into a tail
    x = snakePos[-1][0]; y = snakePos[-1][1]
    td.setCell x, y, td.cell(x,y) + 4   
end function

// initialize the snake
snakePos = []  // list of x,y positions with head at 0
snakePos.push [12,10]  // head
for i in range(10)
    move RIGHT, true  // move to the right, and grow
end for
move RIGHT  // move one more to the right, without growing

// main loop
secPerMove = 0.25
nextDir = curDir
nextMoveTime = time + secPerMove
while true
    // handle input
    dx = key.axis("Horizontal")
    dy = key.axis("Vertical")
    if abs(dx) or abs(dy) then
        if abs(dx) > abs(dy) then
            if dx > 0 then newDir = RIGHT else newDir = LEFT
        else
            if dy > 0 then newDir = UP else newDir = DOWN
        end if
        if abs(newDir - curDir) != 2 then nextDir = newDir
    end if

    // move snake when it's time
    if time > nextMoveTime then
        move nextDir
        nextMoveTime = time + secPerMove
    end if
    yield
end while
Enter fullscreen mode Exit fullscreen mode

Animated GIF of Phase 1 snake

Level 2: Smooth, Bendy Snake Body

The round body segments work, but they don't look much like a snake. To really do this right, we need to take it further.

The big idea here is to have two versions of our body segments: one that's straight, and one that bends. By rotating these images as needed, we can make any part of a windy bendy snake body.

Run /sys/demo/fatbits again, and first make a straight snake body like so:

bodyStraight.png

Note that while we've still left the 4-pixel margin on the sides, the top and bottom are actually 6 pixels away from the edge, because we want to leave a little gap in that direction when the snake has done a U-turn and slithered back right next to itself.

Then, make another image for the body that bends. This would be the one used when the snake has turned from going DOWN to going RIGHT, or conversely, turned from LEFT to UP.

bodyBend.png

Then adjust the head and tail images to mesh smoothly with the new body shape as well.

head.png and tail.png

Now we're ready to adjust the code. Loading the head and tail is exactly the same as before; there's still only one image needed in 4 different orientations. Loading the body is a bit trickier. To do that, we're going to do a bit of math involving the snake's previous direction, and its new direction. Keep in mind that directions are represented as numbers 0-3 (for RIGHT, UP, LEFT, and DOWN). So for example, to store the tile to use when the snake turns from LEFT to DOWN, we'll calculate LEFT * 4 + DOWN (and add 5, since the body images begin at index 5).

makeTileSet = function
    globals.empty = Image.create(32, 32, color.clear)
    globals.images = [empty]*25
    images[1] = loadRotated("head.png", 0)
    images[2] = loadRotated("head.png", 90)
    images[3] = loadRotated("head.png", 180)
    images[4] = loadRotated("head.png", 270)

    // for the body, define image for each valid combination
    // of prevDir, nextDir as 5 + prevDir*4 + newDir
    images[5+RIGHT*4+RIGHT] = loadRotated("bodyStraight.png", 00)
    images[5+UP*4+UP] = loadRotated("bodyStraight.png", 90)
    images[5+LEFT*4+LEFT] = loadRotated("bodyStraight.png", 180)
    images[5+DOWN*4+DOWN] = loadRotated("bodyStraight.png", 270)

    images[5+LEFT*4+UP] = loadRotated("bodyBend.png", 0)
    images[5+DOWN*4+LEFT] = loadRotated("bodyBend.png", 90)
    images[5+RIGHT*4+DOWN] = loadRotated("bodyBend.png", 180)
    images[5+UP*4+RIGHT] = loadRotated("bodyBend.png", 270)
    images[5+DOWN*4+RIGHT] = loadRotated("bodyBend.png", 0)
    images[5+RIGHT*4+UP] = loadRotated("bodyBend.png", 90)
    images[5+UP*4+LEFT]= loadRotated("bodyBend.png", 180)
    images[5+LEFT*4+DOWN] = loadRotated("bodyBend.png", 270)

    images[21] = loadRotated("tail.png", 0)
    images[22] = loadRotated("tail.png", 90)
    images[23] = loadRotated("tail.png", 180)
    images[24] = loadRotated("tail.png", 270)   
    return tileUtil.toTileSet(images)
end function
Enter fullscreen mode Exit fullscreen mode

Having done that, our move function is almost the same as before. There are only two differences; first is where we convert the old head into a new body segment. Instead of

    td.setCell x, y, td.cell(x,y) + 4
Enter fullscreen mode Exit fullscreen mode

we now do

    td.setCell x, y, 5 + curDir * 4 + nextDir
Enter fullscreen mode Exit fullscreen mode

(where curDir is the snake's previous direction, and nextDir is its next direction). The math in this line mirrors the math we used to load the tile set.

The second difference is at the other end, where we convert the last body segment into a tail. This one now looks like:

    td.setCell x, y, 21 + (td.cell(x,y) - 5) % 4    
Enter fullscreen mode Exit fullscreen mode

This is using the modulo operator (%) to find the remainder after dividing the previous index (minus 5, which is where the body segments begin) by 4, leaving us with just right number to represent the direction of the tail. Neat, huh?

Here's the full program listing.
// Snake! (Bendy-body version)

import "tileUtil"

// define our directions
RIGHT = 0
UP = 1
LEFT = 2
DOWN = 3

loadRotated = function(path, angle=0)
    img = file.loadImage(path)
    if img isa Image then
        img.rotate(angle)
        return img
    end if
    print "No such image: " + path
    exit
end function

makeTileSet = function
    globals.empty = Image.create(32, 32, color.clear)
    globals.images = [empty]*25
    images[1] = loadRotated("head.png", 0)
    images[2] = loadRotated("head.png", 90)
    images[3] = loadRotated("head.png", 180)
    images[4] = loadRotated("head.png", 270)

    // for the body, define image for each valid combination
    // of prevDir, nextDir as 5 + prevDir*4 + newDir
    images[5+RIGHT*4+RIGHT] = loadRotated("bodyStraight.png", 00)
    images[5+UP*4+UP] = loadRotated("bodyStraight.png", 90)
    images[5+LEFT*4+LEFT] = loadRotated("bodyStraight.png", 180)
    images[5+DOWN*4+DOWN] = loadRotated("bodyStraight.png", 270)

    images[5+LEFT*4+UP] = loadRotated("bodyBend.png", 0)
    images[5+DOWN*4+LEFT] = loadRotated("bodyBend.png", 90)
    images[5+RIGHT*4+DOWN] = loadRotated("bodyBend.png", 180)
    images[5+UP*4+RIGHT] = loadRotated("bodyBend.png", 270)
    images[5+DOWN*4+RIGHT] = loadRotated("bodyBend.png", 0)
    images[5+RIGHT*4+UP] = loadRotated("bodyBend.png", 90)
    images[5+UP*4+LEFT]= loadRotated("bodyBend.png", 180)
    images[5+LEFT*4+DOWN] = loadRotated("bodyBend.png", 270)

    images[21] = loadRotated("tail.png", 0)
    images[22] = loadRotated("tail.png", 90)
    images[23] = loadRotated("tail.png", 180)
    images[24] = loadRotated("tail.png", 270)   
    return tileUtil.toTileSet(images)
end function

// display set-up
clear
display(4).mode = displayMode.tile
td = display(4)
td.cellSize = 32
td.overlap = 8
td.tileSet = makeTileSet
td.tileSetTileSize = 32
td.extent = [floor(960/24), floor(640/24)]
td.clear 0

// function to move the snake in any direction
move = function(nextDir, grow=false)
    // convert the old head into a body
    x = snakePos[0][0]; y = snakePos[0][1]
    td.setCell x, y, 5 + curDir * 4 + nextDir

    // place the new head   
    if nextDir == RIGHT then x += 1
    if nextDir == UP then y += 1
    if nextDir == LEFT then x -= 1
    if nextDir == DOWN then y -= 1
    td.setCell x, y, 1 + nextDir
    snakePos.insert 0, [x,y]

    // remember our new current direction
    globals.curDir = nextDir

    // if we want to grow the snake, then we're done
    if grow then return

    // remove the old tail
    td.setCell snakePos[-1][0], snakePos[-1][1], 0
    snakePos.pop

    // convert the new last segment into a tail
    x = snakePos[-1][0]; y = snakePos[-1][1]
    td.setCell x, y, 21 + (td.cell(x,y) - 5) % 4    
end function

// initialize the snake
curDir = RIGHT
snakePos = []  // list of x,y positions with head at 0
snakePos.push [12,10]  // head
for i in range(10)
    move curDir, true  // move to the right, and grow
end for
move curDir  // move one more to the right, without growing

// main loop
secPerMove = 0.25
nextDir = curDir
nextMoveTime = time + secPerMove
while true
    // handle input
    dx = key.axis("Horizontal")
    dy = key.axis("Vertical")
    if abs(dx) or abs(dy) then
        if abs(dx) > abs(dy) then
            if dx > 0 then newDir = RIGHT else newDir = LEFT
        else
            if dy > 0 then newDir = UP else newDir = DOWN
        end if
        if abs(newDir - curDir) != 2 then nextDir = newDir
    end if

    // move snake when it's time
    if time > nextMoveTime then
        move nextDir
        nextMoveTime = time + secPerMove
    end if
    yield
end while
Enter fullscreen mode Exit fullscreen mode

Animated GIF of smooth, bendy snake

Conclusion

While Snake is a simple game, and easy enough to draw with only blocky rectangles, making it look good — with nice smooth snaky curves — requires a bit of thought. But now you know how it's done!

As far as I'm aware, nobody has yet made a complete, polished Snake implementation for the Mini Micro platform. Will you be the first?

If you enjoyed this "How Would You Do That" post, be sure to check out HWYDT: Donkey Kong and HWYDT: Ladders! And as always, if you have any questions or thoughts to share, please use the comment form below. Happy coding!

Top comments (1)

Collapse
 
sebnozzi profile image
Sebastian Nozzi

Really nice tutorial!