The post today is about a very common game we all know from the old Nokias.
The idea of this exercise is to review or practice most of the programming elements and techniques that we have been seeing throughout the basic programming course.
Index
The Snake game
In this post we will look at a solution for making this game in .net
1 - Analysis
First of all, we'll make an analysis of the game's requirements, which are basically:
- We have a board or terrain where we can move the snake
- The snake can move
- Up
- Down
- Right
- Left
- Candies appear on the screen for the snake to eat and score points
Therefore, if we convert the real entities into code entities, as we saw in the video about objects and classes, we’ll get the following:
- Board Class
- Properties:
- Height
- Width
- Methods:
- Draw the board
- Properties:
- Snake Class
- Properties:
- Tail = List of positions
- Current direction
- Points
- Methods
- Die
- Move
- Eat the candy
- Properties:
- Candy Class
- Appears
Apart from these classes, we may need to create an additional one, for example a Util
class that draws a line or character on screen, since this is an action we will repeat constantly.
static class Util{ public static void DibujarPosicion(int x, int y, string caracter) { Console.SetCursorPosition(x, y); Console.WriteLine(caracter); }}
As we can see, the Console.SetCursorPosition(x,y)
method places the cursor at the desired point in the terminal.
And as we saw in the video about keyboard and screen input/output, Console.WriteLine(string) writes some text for us, in most cases in this exercise, just a character. For the walls, it uses the #
character or x
for the snake
2 - Code
When we program, we need to think as if we have that object, or objects, in front of us, so the first element we would add is the board. That's why the Board entity will be the first one we create.
2.1 - Board entity
We create the board class with the properties Height
and Width
. Also, since we'll use it later, we create the HasCandy
property and initialize it with false
, because when we initially draw the board, the candy doesn't exist yet.
We must also create the DrawBoard method, which loops through the height and width to draw the specified board.
public class Tablero{ public readonly int Altura; public readonly int Anchura; public bool ContieneCaramelo; public Tablero(int altura, int anchura) { Altura = altura; Anchura = anchura; ContieneCaramelo = false; } public void DibujarTablero() { for (int i = 0; i <= Altura; i++) { Util.DibujarPosicion(Anchura, i, "#"); Util.DibujarPosicion(0, i, "#"); } for (int i = 0; i <= Anchura; i++) { Util.DibujarPosicion(i, 0, "#"); Util.DibujarPosicion(i, Altura, "#"); } }}
2.2 - Snake entity
Of course, it's very important to create the snake entity. For this, let's recall the previous data. As we said, it will contain a list of positions, so Position
itself will be an entity, which will contain the X and Y axis positions.
public class Posicion{ public int X; public int Y; public Posicion(int x, int y) { X = x; Y = y; }}
Besides List<Position>
it should contain the direction it will move in. Since there are only four and they are always the same, we'll create an enumeration for them.
public enum Direccion{ Arriba, Abajo, Izquierda, Derecha}
Using the enumeration makes it much easier to know which direction we’re heading.
Finally, the cla
public class Serpiente{ List<Posicion> Cola { get; set; } public Direccion Direccion { get; set; } public int Puntos { get; set; } public Serpiente(int x, int y) { Posicion posicionInicial = new Posicion(x, y); Cola = new List<Posicion>() { posicionInicial }; Direccion = Direccion.Abajo; Puntos = 0; } public void DibujarSerpiente() { foreach (Posicion posicion in Cola) { Console.ForegroundColor = ConsoleColor.Green; Util.DibujarPosicion(posicion.X, posicion.Y, "x"); Console.ResetColor(); } } public bool ComprobarMorir(Tablero tablero) { throw new NotImplementedException(); } public void Moverse(bool haComido) { throw new NotImplementedException(); } public bool ComeCaramelo(Caramelo caramelo, Tablero tablero) { throw new NotImplementedException(); }}
Finally, the Snake
class. As you can see, we initialize it with a position and then assign default values to Direction
, Points
, and the IsAlive
property, which tells us if it’s alive or when the game is over.
As you can see, I also created the DrawSnake
method, which loops through the tail and prints the snake on the screen using the class we created before. The Console.ForegroundColor
code is used to change the color printed by the console. Console.ResetColor()
restores it to default values.
The rest of the methods have not yet been implemented.
2.3 - Candy entity
Let’s not forget one of the most important parts of the game: when you eat a candy, the size of the snake increases. So we create the Candy
entity and draw it.
public class Caramelo{ public Posicion Posicion { get; set; } public Caramelo(int x, int y) { Posicion = new Posicion(x, y); } public void DibujarCaramelo() { Console.ForegroundColor = ConsoleColor.Blue; Util.DibujarPosicion(Posicion.X, Posicion.Y, "O"); Console.ResetColor(); } public static Caramelo CrearCaramelo(Serpiente serpiente, Tablero tablero) { throw new NotImplementedException(); }}
3 - Program logic
Now that we have the entities defined, even though some methods are still unimplemented, we need to decide on the logic so the snake can move and eat candies.
For this, let’s go to the program class, which, as we remember from the first post, is the class that runs when executing a console application.
We need to draw both the board
and the snake
first. But if we don’t add anything else, the snake will stay still. We remember we have the Direction
property, which tells us which direction the snake will move. And for it to move, we must put it inside a while or do while
loop.
static void Main(string[] args){ Tablero tablero = new Tablero(20, 20); Serpiente serpiente = new Serpiente(10, 10); Caramelo caramelo = new Caramelo(0, 0); bool haComido = false; do { Console.Clear(); tablero.DibujarTablero(); serpiente.Moverse(haComido); haComido = serpiente.ComeCaramelo(caramelo, tablero); serpiente.DibujarSerpiente(); } while (serpiente.ComprobarMorir(tablero)); Console.ReadKey();}
We must initialize both the board
, the snake
, and the first candy
outside the loop, as otherwise we’d restart everything from zero every time.
Also, the first line in the loop should clear the console, using a Console.Clear()
since it does not clear automatically.
Finally, as shown in this small code piece, we must remember that the snake moves first and then eats. It is finally drawn at the end.
3.1 - Snake moves
The first step of the program is to move. So for that we need to change our current position depending on the direction:
- Up: Y -1
- Down: Y+1
- Right: X +1
- Left: X-1
We get the first position of the tail (the head) and update its value as can be seen in ObtainNewFirstPosition()
.
public void Moverse(bool haComido){ List<Posicion> nuevaCola = new List<Posicion>(); nuevaCola.Add(ObtenerNuevaPrimeraPosicion()); nuevaCola.AddRange(Cola); if (!haComido) { nuevaCola.Remove(nuevaCola.Last()); } Cola = nuevaCola;}private Posicion ObtenerNuevaPrimeraPosicion(){ int x = Cola.First().X; int y = Cola.First().Y; switch (Direccion) { case Direccion.Abajo: y += 1; break; case Direccion.Arriba: y -= 1; break; case Direccion.Derecha: x += 1; break; case Direccion.Izquierda: x -= 1; break; } return new Posicion(x, y);}
To move the rest of the snake is quite simple. The whole tail shifts by one value.
So, what we do is put the new value we got as the first value of a new list. Then, we append the previous tail to this new list.
For example, if we have 3 elements:
Position X | Position Y |
10 | 10 |
10 | 12 |
10 | 12 |
The next move is up, that is, Y -1 so the value we’ll get will be:
Position X | Position Y |
10 | 9 |
Then we concatenate the previous list, so we get:
Position X | Position Y |
10 | 9 |
10 | 10 |
10 | 11 |
10 | 12 |
But this list has one value more than desired. Here’s where whether it ate in the previous loop comes in, since if it did, that's fine, we leave it, but if not, we have to remove the last value from the list.
Which gives us this result:
Position X | Position Y |
10 | 9 |
10 | 10 |
10 | 11 |
As we can see, those are the new positions of the snake.
3.2 - Snake eats
To check if the snake has eaten, we must check that the candy isn't in any of the tail positions. It shouldn't happen that it’s created in a tail position, but just in case, we check them all.
Inside the snake class, the code to check if it has eaten is:
public bool ComeCaramelo(Caramelo caramelo, Tablero tablero){ if (PosicionEnCola(caramelo.Posicion.X, caramelo.Posicion.Y)) { Puntos += 10; // add points tablero.ContieneCaramelo = false;//Remove or create a new candy return true; } return false;}public bool PosicionEnCola(int x, int y){ return Cola.Any(a => a.X == x && a.Y == y);}
ComeCaramelo
, returns true
if you eat the candy, and also tells the board that it no longer has candy, which makes it generate a new one.
3.3 - Move in different directions
But of course, as it stands, we only move in one direction. What we need is to let the user enter the direction they want to move.
To do this, inside our loop, we need to add the following code:
var sw = Stopwatch.StartNew();while (sw.ElapsedMilliseconds <= 250){ serpiente.Direccion = Util.LeerMovimiento(serpiente.Direccion);}
The ReadMovement
method will return the Direction
you pressed. If you don't press a new direction in 200 milliseconds, it will automatically return the current direction.
static Direccion LeerMovimiento(Direccion movimientoActual){ if (Console.KeyAvailable) { var key = Console.ReadKey().Key; if (key == ConsoleKey.UpArrow && movimientoActual != Direccion.Abajo) { return Direccion.Arriba; } else if (key == ConsoleKey.DownArrow && movimientoActual != Direccion.Arriba) { return Direccion.Abajo; } else if (key == ConsoleKey.LeftArrow && movimientoActual != Direccion.Derecha) { return Direccion.Izquierda; } else if (key == ConsoleKey.RightArrow && movimientoActual != Direccion.Izquierda) { return Direccion.Derecha; } } return movimientoActual;}
We must avoid going in the opposite direction since that would lead us to hit ourselves and die.
3.4 - Draw the candy
Drawing the candy is very simple. We’ll do something similar to when we draw the snake or the walls. The only difference is that, to draw the candy, we need to make sure of two things.
- It is completely random
- It is not on the walls or the snake.
public void DibujarCaramelo(){ Console.ForegroundColor = ConsoleColor.Blue; Util.DibujarPosicion(Posicion.X, Posicion.Y, "O"); Console.ResetColor();}public static Caramelo CrearCaramelo(Serpiente serpiente, Tablero tablero){ bool carameloValido; int x,y; do { Random random = new Random(); x = random.Next(1, tablero.Anchura - 1); y = random.Next(1, tablero.Altura - 1); carameloValido = serpiente.PosicionEnCola(x, y); } while (carameloValido); tablero.ContieneCaramelo = true; return new Caramelo(x, y);}
3.5 - Die
To die, we'll check only two things.
- The snake head (or first position in the list) is on a wall.
- The snake head is in the rest of the tail.
public void ComprobarMorir(Tablero tablero){ //If we hit ourselves Posicion primeraPosicion = Cola.First(); EstaViva = !((Cola.Count(a => a.X == primeraPosicion.X && a.Y == primeraPosicion.Y) > 1 ) || CabezaEstaEnPared(tablero, Cola.First()));}//If the first position is on any wall, we die.private bool CabezaEstaEnPared(Tablero tablero, Posicion primeraPoisicon){ return primeraPoisicon.Y == 0 || primeraPoisicon.Y == tablero.Altura || primeraPoisicon.X == 0 || primeraPoisicon.X == tablero.Anchura;}
3.6 - Put the parts together
Finally, to make everything work correctly we must put all the parts together in a sensible order. To do this, inside the main
class, we’ll divide the code as follows.
First, as we’ve seen before, we initialize the variables we need, these include: the board
, the snake
, the candy
and the ateCandy
variable.
Tablero tablero = new Tablero(20, 20);Serpiente serpiente = new Serpiente(10, 10);Caramelo caramelo = new Caramelo(0, 0);bool haComido = false;
Once everything is initialized, we need to draw it, recalling that everything is in a do While
loop. Inside the loop, first of all, we must clear the screen and then draw everything, as long as the snake is alive.
We can check if it’s alive either at the beginning or the end of the loop, it doesn’t matter. But if we check at the start, we must process movements, snake and candy creation only while alive. Otherwise we show a message that we've died and the score.
The final code of the main class is as follows:
static void Main(string[] args){ Tablero tablero = new Tablero(20, 20); Serpiente serpiente = new Serpiente(10, 10); Caramelo caramelo = new Caramelo(0, 0); bool haComido = false; do { Console.Clear(); tablero.DibujarTablero(); //Move and check if it ate last turn. serpiente.Moverse(haComido); //Check if candy was eaten haComido = serpiente.ComeCaramelo(caramelo, tablero); //Draw snake serpiente.DibujarSerpiente(); //If there's no candy, create a new one. if (!tablero.ContieneCaramelo) { caramelo = Caramelo.CrearCaramelo(serpiente, tablero); } //Draw candy caramelo.DibujarCaramelo(); //Read direction from keyboard. var sw = Stopwatch.StartNew(); while (sw.ElapsedMilliseconds <= 250) { serpiente.Direccion = Util.LeerMovimiento(serpiente.Direccion); } } while (serpiente.ComprobarMorir(tablero)); Util.MostrarPuntuación(tablero, serpiente); Console.ReadKey();}
You can see an image of the completed exercise:
If there is any problem you can add a comment bellow or contact me in the website's contact form