Learning F#: How to make a snake game in the console

My favorite way to learn new things is by diving headfirst into a concept and start hacking away! The classic mobile game Snake should be a good challenge for a beginner F# developer.

We'll be making the game as a console application to keep things simple. Two methods that are important for game development, start() and loop(). The start method provides the initial game context and the loop method progresses the game.

Rules

  • A snake game is made up of a grid, a snake, and apples
  • The snake can move in four directions, up, down, left, right
  • The snake eats apples to grow in length
  • An apple spawns after the snake has eaten an apple
  • The game is over when the snake crashes into a wall or into itself

Creating the types

type GameObject = Ground | Snake | Food

type Direction = Up | Down | Left | Right | None

type GameContext = {
    Map: (GameObject * Direction) [,];
    Direction: Direction;
    Position: int * int;
    Score: int;
    StepsLeft: int;
    Dead: bool;
}

First of all, we need to establish our types. More precisely, our enumerations and records. To distinguish between the ground, the snake, and the food, I've chosen to make enums. The same goes for the directions. It makes the code easier to read.

I've also made a record that we use to pass into our game loop. It consists of:

  • Map: A 2-dimensional array made out of tuples of game objects and a direction. A tuple is a way to store many items into one variable, so game object and direction are tied together here (GameObject.Ground, Direction.None).
  • Direction: Direction is the direction that the player has inputted into the game.
  • Position: Current position of the snakehead.
  • Score: Score gained by eating apples.
  • StepsLeft: Steps left to the snake starves.
  • Dead: The snake is dead and the game is over.

Creating the game context

let createGameContext =
    let map = Array2D.init 18 18 (fun _ _ -> (GameObject.Ground, Direction.None))
    map.[8, 8] <- (GameObject.Snake, Direction.Up)
    let foodX, foodY = createFood(map)
    map.[foodX, foodY] <- (GameObject.Food, Direction.None)
    { Map = map
      Direction = Direction.Up
      Position = (8, 8)
      Score = 0
      StepsLeft = 400
      Dead = false }

Here we create a 2D array with Array2D.init to make our 18x18 grid for the positions of the game elements. Initializing the snake position at the 8th index of the y and x-axis with a direction up. We assign this value by using the <- operator.

We generate the position for the apple with the create food method.

let rec createFood (map : (GameObject * Direction)[,]) =
    let rnd = System.Random()
    let x = rnd.Next(0, 18)
    let y = rnd.Next(0, 18)
    let obj, _ = map.[x, y]
    if obj = GameObject.Snake then
        createFood(map)
    else
        (x, y)

This method selects and returns a random x and y position between the map size, 0 and 18. createFood is a recursive method and uses the rec keyword. That means that the method calls itself. The recursion happens when the apple position collides with the snake and finds a different position.

After the snake and the apple has been placed, we can return the record for our initial game context.

The entry point for the console in F#

In F#, you need to specify an entry point for your console application. This can be done with the [<EntryPoint>] attribute. Call the main method with the create game context described earlier.

let rec main gameContext =
    if gameContext.Dead then Environment.Exit 0
    Console.Clear()
    Renderer.renderMap(gameContext.Map, gameContext.Score, gameContext.StepsLeft)
    Threading.Thread.Sleep(200)
    
    if Console.KeyAvailable then
        match Console.ReadKey().Key with
        | ConsoleKey.Q -> Environment.Exit 0
        | ConsoleKey.UpArrow ->
            main(Game.gameLoop({ gameContext with Direction = Direction.Up }))
        | ConsoleKey.DownArrow ->
            main(Game.gameLoop({ gameContext with Direction = Direction.Down }))
        | ConsoleKey.LeftArrow ->
            main(Game.gameLoop({ gameContext with Direction = Direction.Left }))
        | ConsoleKey.RightArrow ->
            main(Game.gameLoop({ gameContext with Direction = Direction.Right }))
        | _ -> main(Game.gameLoop(gameContext))
    else
        main(Game.gameLoop(gameContext))
        
[<EntryPoint>]
main Game.createGameContext

main is a recursive method that handles the rendering to console and the game loop. We sleep the application for 200 milliseconds to give players time to react. renderMap is a simple print function that loops through the map and prints the elements with letters describing the game objects (S = Snake, F = Food, . = Ground).

let renderMap (map : (GameObject * Direction)[,], score : int, steps : int) =
    for x = 0 to Array2D.length2 map - 1 do
        for y = 0 to Array2D.length1 map - 1 do
            let obj, _ = map.[x, y]
            match obj with
            | GameObject.Snake -> printf "S"
            | GameObject.Food -> printf "F"
            | GameObject.Ground -> printf "."
        printf "\n"
    printfn $"Score: %d{score}"
    printfn $"Steps left: %d{steps}"

The main method also reads the key pressed and runs the game loop with the given direction. We pass on a copy of our game context with the direction.

The game loop

The game loop takes and returns a game context record according to what the next step is doing.

let gameLoop (gameContext : GameContext) =
    if gameContext.StepsLeft = 0 then
        { gameContext with Dead = true }
    else
        let x, y = gameContext.Position
        let newX, newY =
            match gameContext.Direction with
            | Direction.Up -> (x - 1, y)
            | Direction.Down -> (x + 1, y)
            | Direction.Left -> (x, y - 1)
            | Direction.Right -> (x, y + 1)
            | _ -> (x, y)
        
        try
            let obj, _ = gameContext.Map.[newX, newY]
            match obj with
            | GameObject.Snake -> { gameContext with
                                        Dead = true
                                        Position = (newX, newY)
                                    }
            | GameObject.Food -> { gameContext with
                                       Map = modifyHitFood(gameContext.Map, (newX, newY), gameContext.Direction)
                                       Position = (newX, newY)
                                       Score = gameContext.Score + 10
                                       StepsLeft = gameContext.StepsLeft + 100
                                   }
            | GameObject.Ground -> { gameContext with
                                       Map = modifyHitGround(gameContext.Map, (newX, newY), gameContext.Direction)
                                       Position = (newX, newY)
                                       StepsLeft = gameContext.StepsLeft - 1
                                   }
        with
        | :? System.IndexOutOfRangeException -> { gameContext with Dead = true }

The direction sets a new position for the snakes head. If the snakes head hits itself or is out of the maps bounds by throwing an IndexOutOfRangeException, the game is over and we return Dead as true.

If the snakes head hits food, we generate a new food postion on the map with the modifyHitFood method.

let modifyHitFood (map : (GameObject * Direction)[,],(x, y) : int * int,direction : Direction) =
    map.[x, y] <- (GameObject.Snake, direction)
    let foodX, foodY = createFood(map)
    map.[foodX, foodY] <- (GameObject.Food, Direction.None)
    map

If the snakes head hits ground, need to just move the snake a step with the modifyHitGround method.

let modifyHitGround (map : (GameObject * Direction)[,], (x, y) : int * int, direction : Direction) =
    map.[x, y] <- (GameObject.Snake, direction)
    let tailX, tailY = findTail(map, (x, y))
    map.[tailX, tailY] <- (GameObject.Ground, Direction.None)
    map

We need to find the tail of the snake and remove it. With recursion we can traverse backwards from the head position and through the snake body with the given directions.

let rec findTail(map : (GameObject * Direction)[,],(x, y) : int * int) =
    let _, dir = map.[x, y]
    let newX, newY =
        match dir with
        | Direction.Up -> (x + 1, y)
        | Direction.Down -> (x - 1, y)
        | Direction.Left -> (x, y + 1)
        | Direction.Right -> (x, y - 1)
        | _ -> (x, y)
    
    let obj, _ = map.[newX, newY]
    if obj = GameObject.Snake then
        findTail(map, (newX, newY))
    else
        (x, y)

Running the game in the console

Run the game by running dotnet run and it should look something like this.

An animation of a snake game

The source code for this blog post is available on Github.