Generated by DocFX

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.