Snake Tutorial Part 3: The Snake
In the previous chapter, we covered loading content and displaying it on the screen, as well as bringing in user input that we can use to change what is displayed to the screen.
In this chapter, we'll set up the actual snake that the player will be controlling.
At the end of this chapter, your game application will show a moving snake that can be controlled by the player.
Prerequisites
To complete the steps outlined in this chapter, you will need to have completed the steps in the previous chapter
Preface: The Nature of Snake
As we work towards re-creating this classic game, we're bound to learn some things about it that we might not have noticed, or simply hadn't thought of.
For instance, had you realized that Snake takes place on a grid? The snake moves in 90-degree, fixed increments, at a fixed rate. The apple also spawns at locations aligned to this grid.
And speaking of the snake moving at a fixed rate with a consistent observable delay, that means the game also runs on a clock system that's separate from the game's update rate.
We will need to account for both of these traits in our recreation of the game, which we will cover in this chapter.
Step 1: The Snake Class
The next step we'll take on this journey is to create the Snake
class, which will represent our snake. Create a new file called Snake.cs
at core/class/Snake.cs
, and for now populate it with the following:
using System;
using System.Collections.Generic;
using Microsoft.Xna.Framework;
public class Snake
{
public Snake(int length, Vector2 startPosition)
{
Length = length;
Positions.Add(startPosition);
}
public List<Vector2> Positions { get; private set; } = new List<Vector2>();
public Vector2 Head { get => Positions[0]; }
public Vector2 Tail { get => Positions[Positions.Count - 1]; }
public int Length { get; private set; }
}
As we mentioned in the previous section, Snake is a grid-based game. This means that essentially, the "snake" is simply a list of positions on that grid. As the snake "moves", a new position is added in front of it, and its rear-most position is removed.
This is how we will give the sense of motion in our game as well, so our Snake
class will contain a list of positions (Position
) which makes up its body, and to help us with the movement code down the line, we'll also add some properties that make it easy to find the snake's first (Head
) and last (Tail
) position elements. We also have a Length
property that will represent how long our snake is.
This is all we need in Snake.cs
for now, but we will be returning to it very soon.
Step 2: Preparing for Movement
Our snake will be able to move in one of four directions at any given time. To represent this, we will be creating an enumerator called Direction
.
Create a new file called Direction.cs
at core/class/Direction.cs
, and populate it with the following:
public enum Direction{Up, Down, Left, Right}
That one line is all we are going to need in Direction.cs
. Now that we've created that, we're going to return to our Snake.cs
file and add a Direction
property:
public Direction Direction { get; private set; } = Direction.Right;
We'll use this property to track which direction the snake is currently moving in.
The Move Method
Now that our snake knows where its body segments are and knows what direction it is facing, we can add our Move()
method, which will control the snake's movement.
Add the following to Snake.cs
:
public void Move()
{
var newX = Head.X;
var newY = Head.Y;
switch (Direction)
{
case Direction.Up:
newY = Head.Y - 1;
break;
case Direction.Down:
newY = Head.Y + 1;
break;
case Direction.Left:
newX = Head.X - 1;
break;
case Direction.Right:
newX = Head.X + 1;
break;
}
var newPosition = new Vector2(newX, newY);
Positions.Insert(0, newPosition);
if (Positions.Count > Length)
{
Positions.Remove(Tail);
}
}
When invoked, Move()
will add the position "in front" of the snake based on Direction
, and will remove the Tail
segment. This simultanous add/remove operation will give the illusion of motion each time Move()
is called.
Now that we have everything we need to move our snake, let's move over to our MainScene
class and get it ready to create a snake and try to Move()
it.
Step 3: Getting our Snake Moving
As mentioned in the preface of this chapter, Snake runs on an internal clock, or a tick-based system that is independent of the update loop. We will have to update MainScene
to accommodate this.
First, add the following fields to MainScene
in core/scene/MainScene.cs
:
public const int GRID_CELL_SIZE = 16;
public const int GRID_SIZE = 30;
private int _tickDelay = 10;
private int _tickTimer;
private Snake _snake;
We define a few constants, GRID_CELL_SIZE
and GRID_SIZE
which we'll use to define the dimensions of our grid. In fact, we should use these to set the window size of our game to match the play area. We can do this back in Program.cs
's Main()
method:
static void Main()
{
using (var game = new Game())
{
//New section: Window sizing
game.GraphicsDeviceManager.PreferredBackBufferHeight = MainScene.GRID_CELL_SIZE * MainScene.GRID_SIZE;
game.GraphicsDeviceManager.PreferredBackBufferWidth = MainScene.GRID_CELL_SIZE * MainScene.GRID_SIZE;
game.GraphicsDeviceManager.ApplyChanges();
game.LoadScene<MainScene>();
game.Run();
}
}
Back in MainScene.cs
, the new _tickDelay
and _tickTimer
fields will represent the delay between each tick and the actual timer that is keeping track of when to execute the next tick, respectively.
We also created a _snake
field which will track our Snake
instance.
We'll need to initialize _tickTimer
to be equal to _tickDelay
, as well as initialize our Snake
in MainScene.Initialize()
, as such:
protected override void Initialize()
{
Console.WriteLine("Main Scene Initialized!");
_keyboard = new KeyboardMonitor();
_tickTimer = _tickDelay; // <-- New item
_snake = new Snake(4, new Vector2(2, 2)); // <-- New item
}
Now that we have our timer fields and our snake created and initialized, we need to modify MainScene
's Update()
method to use them.
Modify MainScene.Update()
as follows:
protected override void Update(GameTime gameTime)
{
_keyboard.BeginUpdate(Keyboard.GetState());
// We'll be replacing these user input actions with more
// snake-appropriate actions soon. For now, we'll leave them be
if (_keyboard.CheckButton(Keys.Up, InputState.Down))
{
_snakeSprite.Transform.Move(0, -10);
}
if (_keyboard.CheckButton(Keys.Left, InputState.Down))
{
_snakeSprite.Transform.Move(-10, 0);
}
if (_keyboard.CheckButton(Keys.Right, InputState.Down))
{
_snakeSprite.Transform.Move(10, 0);
}
if (_keyboard.CheckButton(Keys.Down, InputState.Down))
{
_snakeSprite.Transform.Move(0, 10);
}
_keyboard.EndUpdate();
//New section: tick handling
if (_tickTimer <= 0)
{
_snake.Move();
_tickTimer = _tickDelay;
}
else
{
_tickTimer--;
}
}
With this done, our tick handling is in place, and we've created a Snake
instance and are calling its Move()
method. If run, your game application would instantiate a Snake
, and move it to the right one space per tick. However, since we aren't yet drawing our Snake
, you wouldn't see this happening!
Step 4: Drawing our Snake
Now that we have our MainScene
instantiating and moving a Snake
, we need to update our Draw()
method to draw the Snake
to the screen.
In core/scene/MainScene.cs
, we'll be updating MainScene.Draw()
to look like the following:
protected override void Draw(GameTime gameTime)
{
SpriteBatch.Begin();
foreach (var segment in _snake.Positions)
{
SpriteBatch.Draw(
_snakeSprite.Texture,
new Rectangle((int)segment.X * GRID_CELL_SIZE, (int)segment.Y * GRID_CELL_SIZE, GRID_CELL_SIZE, GRID_CELL_SIZE),
_snakeSprite.Frame,
Color.White
);
}
SpriteBatch.End();
}
We iterate over each of the locations in _snake.Positions
and draw our _snakeSprite
to the appropriate location on screen, translating each Position
value to a spot on screen by using GRID_CELL_SIZE
.
Now, if you were to run the application, you should see the snake move across the screen. It will move to the right, and will even keep going once it's reached the right edge of the screen, and will keep going off-screen until the application is closed.
We can't even change our snake's direction to keep it on the screen! Let's change that.
Step 5: Controling our Snake
Currently, in MainScene.Update()
, we're responding to user input by attempting to move _snakeSprite
directly. Since we're not using _snakeSprite
's location information to draw the snake (instead we're using Snake.Positions
), this isn't going to have any effect on our game.
What we need to do in order to move our snake is to change its Direction
property -- but there are some caveats to this:
- Our snake cannot switch from any one direction to any other. If it is facing to the right -- it cannot simply turn around and face left, at least not in one move. Even if that were possible, it would run into itself and immediately cause the game to end!
- We are checking for user input every frame, but really the snake should only change direction once per tick. We can't check for user input only on the tick unless we want to frustrate our players.
To get around these two caveats, we're going to do a few things: implement a Snake.SetDirection()
method, add a new Direction
property called NextDirection
, update our current Direction
property's name to CurrentDirection
, and adjust Snake.Move()
to use these new properties.
First, we'll rename Direction
and add NextDirection
to Snake
in core/class/Snake.cs
:
// We'll be removing this property and replacing it
//Direction Direction { get; private set; } = Direction.Right;
Direction CurrentDirection { get; private set; }
Direction NextDirection { get; private set; } = Direction.Right
Now, with those in place, we'll make some adjustments to the Move()
method so that it uses our new properties:
public void Move()
{
var newX = Head.X;
var newY = Head.Y;
CurrentDirection = NextDirection; //<-- New item
switch (CurrentDirection) //<-- Changed from Direction to CurrentDirection
{
case Direction.Up:
newY = Head.Y - 1;
break;
case Direction.Down:
newY = Head.Y + 1;
break;
case Direction.Left:
newX = Head.X - 1;
break;
case Direction.Right:
newX = Head.X + 1;
break;
}
var newPosition = new Vector2(newX, newY);
Positions.Insert(0, newPosition);
if (Positions.Count > Length)
{
Positions.Remove(Tail);
}
}
Next, with our Move()
method updated with the new properties, we can add our new SetDirection()
method to Snake
:
public void SetDirection(Direction newDirection)
{
if (newDirection == CurrentDirection)
{
// We don't need to do anything if we're telling the snake
// to go where it's already going!
return;
}
// Here, we'll check if the user is trying to tell the snake
// to go in the opposite direction than its current direction.
// If so, we'll simply ignore the request.
switch (newDirection)
{
case Direction.Up:
if (CurrentDirection != Direction.Down)
{
NextDirection = newDirection;
}
break;
case Direction.Down:
if (CurrentDirection != Direction.Up)
{
NextDirection = newDirection;
}
break;
case Direction.Left:
if (CurrentDirection != Direction.Right)
{
NextDirection = newDirection;
}
break;
case Direction.Right:
if (CurrentDirection != Direction.Left)
{
NextDirection = newDirection;
}
break;
}
}
Finally, with our adjustments and new SetDirection()
method in place, we can fix our user input in MainScene.Update()
and let the player actually control the snake!
In core/scene/MainScene.cs
, replace the _snakeSprite.Transform.Move()
calls in MainScene.Update()
with _snake.SetDirection()
calls, as follows:
protected override void Update(GameTime gameTime)
{
_keyboard.BeginUpdate(Keyboard.GetState());
if (_keyboard.CheckButton(Keys.Up, InputState.Down))
{
_snake.SetDirection(Direction.Up); //<-- New item: method replacement
}
if (_keyboard.CheckButton(Keys.Left, InputState.Down))
{
_snake.SetDirection(Direction.Left); //<-- New item: method replacement
}
if (_keyboard.CheckButton(Keys.Right, InputState.Down))
{
_snake.SetDirection(Direction.Right); //<-- New item: method replacement
}
if (_keyboard.CheckButton(Keys.Down, InputState.Down))
{
_snake.SetDirection(Direction.Down); //<-- New item: method replacement
}
_keyboard.EndUpdate();
if (_tickTimer <= 0)
{
_snake.Move();
_tickTimer = _tickDelay;
}
else
{
_tickTimer--;
}
}
Now, if you run your application, you should be able to control the snake!
We are very close to having a complete, working recreation of Snake, but you may have noticed that some key elements are still missing:
- Our snake can leave the game window. The game should end when the snake hits a wall!
- There aren't any apples! How is our snake supposed to grow if there aren't any apples?
Have you noticed what these missing elements have in common? They both involve collision detection and handling, which will be the primary focus of our next chapter.
Conclusion
We covered a lot in this chapter, all related to creating our Snake
class, making it move, and controlling its direction through user input. We also made some adjustments to our MainScene
class in order to facilitate our snake's movement.
Next Steps
When you are ready to move on, jump into the next chapter, where we'll wrap up this Snake tutorial series by adding collision detection, apples, and scoring to our Snake game!
Complete code for this chapter can be found here.