Research collaborate build

Oct 4, 2022

How I Rebuilt the OG Snake Game using Flutter

With code snippets, work-in-progress screenshots and a complete app demonstration, this blog can be your go-to guide if you want to build a snake game using Flutter that works on almost all platforms.
Hemanth Kumar B
Hemanth Kumar BSenior Software Engineer - I
lines

Getting Started

The challenge is to recreate the nostalgic snake game — the one we played on our Nokia phones for hours! This Snake II app in Flutter is versatile and can be run on pretty much any platform.

Let’s learn and understand the steps I followed along the way -

  • Approach: To determine the snake's moveable area and position
  • Movement: Automatically move the snake
  • A Growing Snake: When the snake eats food it expands its length
  • Food: Randomly appears on the screen

Approach:

Here I’m taking the Matrix approach to solve this and determine the snake position by (x, y) coordinates. Firstly, let’s decide the number of columns and rows in the matrix and the size of each cell which is a square.

int xcount = 22, ycount = 35;
double cellSize = 16;

So, our width and height of the snake moveable area would be,

width = xcount * cellSize and height = ycount * cellSize

totalCells = xcount * ycount

For instance, I can generate food position by getting a random number between 0 to totalCells, then I can use that position to convert into offset. We are going to use Widget Stack and Position to Rendering our Snake and Food on the screen.

Snake:

The snake is just a list of coordinates, the length of this list will determine the length of the snake, we can define each coordinate as one snake’s body, and then we can simply render the snake according to their coordinate using the Position widget.

Let’s define the snake body:

class SnakeBody {
  int position = 0;
  Direction direction = Direction.right;
  Offset offset = Offset.zero;
  SnakeBody({
    required this.position,
    required this.direction,
required this.offset,

});

}

Here direction is used to save the current direction of each snake body moving at the moment. We can define the initial snake:

snakeBodys = [
SnakeBody(

position: 100,

direction: Direction.right,

offset: getOffsetforPos(100),

),

SnakeBody(

position: 101,

direction: Direction.right,

offset: getOffsetforPos(101),

),

SnakeBody(

position: 102,

direction: Direction.right,

offset: getOffsetforPos(102),

),

SnakeBody(

position: 103,

direction: Direction.right,

offset: getOffsetforPos(103),

),

SnakeBody(

position: 104,

direction: Direction.right,

offset: getOffsetforPos(104),

),

SnakeBody(

position: 105,

direction: Direction.right,

offset: getOffsetforPos(105),

),

];

Here we are using the getOffsetforPos function to get offset by position:

Offset getOffsetforPos(int pos) {
return Offset(
((pos % xcount) * 16) + 8, ((pos ~/ xcount % ycount) * 16)
+ 8);

}

This step will give us the center offset for the given position in the matrix.

Moving ahead, we can now render this:

Container(
decoration: BoxDecoration(border: Border.all(width: 2)),constraints: const BoxConstraints(
    maxWidth: 352 + 16,
    minWidth: 352 + 16,
    maxHeight: 560 + 16,
    minHeight: 560 + 16),
    child: Center(
      child: Stack(
        children: [
          ...List.generate(
              snakeBodys.length,
              (index) => Positioned(
                        left: snakeBodys[index].offset.dx,
                        top: snakeBodys[index].offset.dy,
                        child: getSnakeBody(
                            snakeBodys[index],
                            index,
                            snakeBodys.length)),
                  ),
      ],
    ),
  ),
),

Here getSnakeBody will get an appropriate snake body based on where they are, we know the front of the snake should be the head and the end should be the tail, hence it looks more like a snake than just a square.

Therefore, in order to make it seem right, we rotate the head and tail in the appropriate directions.

NOTE: You can skip this part and simply retain it as a square container with a width and height of 16.

Widget getSnakeBody(SnakeBody snake, int index, int length) {index = index + 1;

if (index == 1) {

return RotatedBox(

quarterTurns: snake.direction == Direction.down

? 1
            : snake.direction == Direction.up
? -1
                : snake.direction == Direction.left
                    ? 2
                    : 0,
        child: tail);
  } else if (index == length) {
    return RotatedBox(
        quarterTurns: snake.direction == Direction.down
            ? 1
            : snake.direction == Direction.up
                ? -1
                : snake.direction == Direction.left
                    ? 2
                    : 0,
        child: openMouth(snake.position, food.position, 
snake.direction)
            ? eatingHead
            : snakeHead);
  } else {
    return normalBody;
  }
}

I have defined these body parts already in a different file, you can find them in the repo and openMouth. It basically determines if an open mouth snake head widget should be used in place of the head.

Screenshot of the Snake Game

Food:

Just like how we rendered the snake using position and offset, we can do the same for Food.

Let’s also define Food

class Food {
int position = 0;

Offset offset = Offset.zero;

int count = 0;

Food({

required this.position,

required this.offset,

});

}

Here count keeps track of how much food the snake has eaten.

We assign position zero initially because we are going to change that before the game starts

Food food = Food(position: 0, offset: Offset.zero);

Now we can render the Food on screen

Stack(
children: [

...List.generate(

snakeBodys.length,

(index) => Positioned(

left: snakeBodys[index].offset.dx,

top: snakeBodys[index].offset.dy,

child: getSnakeBody(
snakeBodys[index],
                    index,
                    snakeBodys.length),
          )),
  Positioned(
      left: food.offset.dx,
      top: food.offset.dy,
      child: snakeFood);
],
)

Automatic Movement:

We must determine the direction in which the snake is moving in order to determine its next position.

For example, if I take the initial snake positions have [1,2,3,4,5,6] that is moving in the right direction then we will add the next position to the last of our array because it’s moving in the right next position will be 7 so we add it at the end and remove the first so it becomes [2,3,4,5,6,7]

Here it’s very important to calculate the next position properly depending on the direction it’s moving.

We can have an enum for Direction:

enum Direction { up, down, left, right }

We can also have an initial function to initiate all our variables.

/// Init Board or Reset board
/// and variables

initBoard() {
snakeBodys = [
    SnakeBody(
      position: 100,
      direction: Direction.right,
      offset: getOffsetforPos(100),
    ),
    SnakeBody(
      position: 101,
      direction: Direction.right,
      offset: getOffsetforPos(101),
    ),
    SnakeBody(
      position: 102,
      direction: Direction.right,
      offset: getOffsetforPos(102),
    ),
    SnakeBody(
      position: 103,
      direction: Direction.right,
      offset: getOffsetforPos(103),
    ),
    SnakeBody(
      position: 104,
      direction: Direction.right,
      offset: getOffsetforPos(104),
    ),
    SnakeBody(
      position: 105,
      direction: Direction.right,
      offset: getOffsetforPos(105),
    ),
  ];
  do {
    food.position = Random().nextInt(770);
} while ([100, 101, 102, 103, 104, 
105].contains(food.position));
  direction = Direction.right;
  food.offset = getOffsetforPos(food.position);
  food.count = 0;
  totalSpot = List.generate(770, (index) => index);
  score = 0;
}

In order to actually move the snake, we must use the Timer.periodic, which takes a callback function and a duration. Here, we pass the updateSnake function with a time of 300 milliseconds. It, thus, calls the updateSnake function every 300 milliseconds.

updateSnake function calculates the next snake position depending on where the current snake’s last element in the list is also here snake can move one end to another end lets consider the matrix of 4*4 and the snake size is one and it is in the 15th cell and moving down

1 2 3 4

5 6 7 8

9 10 11 12

13 14 15 16

So, if it’s moving down then the next position of that snake should be 3, we can calculate this using xcount, ycount and totalcells

updateSnake() {
/// There are no walls in this game we need to make sure the

snake moves through

/// and comes from the other side

setState(() {

switch (direction) {

case Direction.down:

// If Snake is already on the Last Row of the matrix

// we need to make it come from top on the same column

if (snakeBodys.last.position > 748) {

snakeBodys.add(SnakeBody(

position: snakeBodys.last.position - 770 + xcount,

direction: direction,

offset:

getOffsetforPos(snakeBodys.last.position - 770 +

xcount)));

} else {

// else we just move to the next row of matrix

snakeBodys.add(SnakeBody(
position: snakeBodys.last.position + xcount,
direction: direction,
              offset: getOffsetforPos(snakeBodys.last.position + 
xcount)));
        }
        break;
      case Direction.up:
        // If the snake is already at the Top row of the matrix
        // we need to make it come from the bottom on the same 
column
        if (snakeBodys.last.position < xcount) {
          snakeBodys.add(SnakeBody(
              position: snakeBodys.last.position + 770 - xcount,
              direction: direction,
              offset:
                  getOffsetforPos(snakeBodys.last.position + 770 -
xcount)));
        } else {
          // else we just move to the next row of the matrix
          snakeBodys.add(SnakeBody(
              position: snakeBodys.last.position - xcount,
              direction: direction,
              offset: getOffsetforPos(snakeBodys.last.position - 
xcount)));
        }
        break;
      case Direction.right:
        // If the snake is already at the last column of the 
matrix
        // we need to make it come from the first column that is 
left
        if ((snakeBodys.last.position + 1) % xcount == 0) {
          snakeBodys.add(SnakeBody(
              position: snakeBodys.last.position + 1 - xcount,
              direction: direction,
              offset:
                  getOffsetforPos(snakeBodys.last.position + 1 - 
xcount)));
        } else {
          snakeBodys.add(SnakeBody(
              position: snakeBodys.last.position + 1,
              direction: direction,
              offset: getOffsetforPos(snakeBodys.last.position + 
1)));
        }
break;
      case Direction.left:
        // If the snake is already at the first column of the 
matrix
        // we need to make it come from the first column that is 
left
        if (snakeBodys.last.position % xcount == 0) {
          snakeBodys.add(SnakeBody(
              position: snakeBodys.last.position - 1 + xcount,
              direction: direction,
              offset:
                  getOffsetforPos(snakeBodys.last.position - 1 + 
xcount)));
        } else {
          snakeBodys.add(SnakeBody(
              position: snakeBodys.last.position - 1,
              direction: direction,
              offset: getOffsetforPos(snakeBodys.last.position - 
1)));
        }
        break;
      default:
    }
    // If the Snake’s last position is head is the same as the 
Food position
    // and get a new food position
    if (snakeBodys.last.position == food.position) {
      /// We can't have food generated on positions of the snake 
body
      totalSpot.removeWhere((element) =>
          snakeBodys.map((e) => 
e.position).toList().contains(element));
      /// get new position for food once it’s eaten by snake
      food.position = totalSpot[Random().nextInt(totalSpot.length 
- 1)];
      food.offset = getOffsetforPos(food.position);
      // increase count
      food.count += 1;
      // Add score
      score = score + 5;
      /// This is to repopulate Total available spots to also 
increases randrom postions
      if (totalSpot.length < (770 / 2)) {
        totalSpot = List.generate(770, (index) => index);
      }
    }
    else {
      /// If Snake didn't eat any food we need to remove it first
      /// element to keep snake length same
      snakeBodys.removeAt(0);
    }
});
}

Now that we have a method to update the snake's next position, we can use Timer.periodic so that snakes start moving every 300 milliseconds.

Putting it all together in one method,

/// Starts the Snake moving
startGame() {

// indicates that the game has started

start = true;

// Reset Board

initBoard();

// Cancel Timer if the old timer is running

if (timer != null) {

timer!.cancel();

timer == null;

}

// Assign new timer

timer = Timer.periodic(

Duration(

milliseconds: speeds[

Provider.of<MenuAndSettingsProvider>(context,

listen: false)

.level]!), (timer) {

/// This will be called every duration

// Call update snake to update to a new position

updateSnake();

// after the snake’s new position is updated get the current

positions of

// snakes list and check whether positions are repeated
// if it is game over else continue
    final copyList = List.from(snakeBodys.map((e) => 
e.position)).toList();
    if (snakeBodys.length > copyList.toSet().length) {
      start = false;
      this.timer!.cancel();
      this.timer = null;
    }
  });
}

Voila, the snake can now move! But the snakes keep moving forever in the same Right direction we initialized in but we can change this by incorporating the use of touch drag and keyboard arrows to change the snake direction.

Changing Snake direction:

We can change the direction of the snake using GestureDetector widget, which we can used to detect drag directions of both touch and the mouse, although we can use the mouse on the web we can use the keyboard for input using RawKeyboardListener widget and change the direction by ARROWS or AWSD keys. Now let’s wrap our Snake rendering widget

/// For detecting touch drag
GestureDetector(
  // for checking up and down
  onVerticalDragUpdate: (details) {
    if (dead) {
      return;
    }
    if (start) {
      if (direction != Direction.up && details.delta.dy > 0) {
        if (direction != Direction.down &&
            direction != Direction.up) {
          direction = Direction.down;
        }
      }
      if (direction != Direction.down && details.delta.dy < 0) {
        if (direction != Direction.up &&
            direction != Direction.down) {
          direction = Direction.up;
        }
      }
    } else {
      /// If the game is not started and did drag action
      /// this starts the game
      startGame();
    }
  },
  // for checking left or right
  onHorizontalDragUpdate: (details) {
    if (dead) {
      return;
    }
    if (start) {
      if (direction != Direction.left && details.delta.dx > 0) {
        if (direction != Direction.right &&
            direction != Direction.left) {
          direction = Direction.right;
        }
      }
      if (direction != Direction.right && details.delta.dx < 0) {
        if (direction != Direction.left &&
            direction != Direction.right) {
          direction = Direction.left;
        }
      }
    } else {
      /// If the game is not started and did drag action
      /// this starts the game
      startGame();
    }
  },
  // For listen to key events
  child: RawKeyboardListener(
    autofocus: true,
    focusNode: FocusNode(),
    // We will check which key is pressed and change the direction
    // accordingly
    onKey: (value) {
      if (dead) {
        return;
      }
      if (value.isKeyPressed(LogicalKeyboardKey.escape)) {
        Navigator.of(context).push(MaterialPageRoute(
          builder: (context) => const WelcomeScreen(),
        ));
      }
      if (start) {
        if (value.isKeyPressed(LogicalKeyboardKey.arrowUp) ||
            value.isKeyPressed(LogicalKeyboardKey.keyW)) {
          if (direction != Direction.up &&
              direction != Direction.down) {
            direction = Direction.up;
          }
        } else if 
(value.isKeyPressed(LogicalKeyboardKey.arrowDown) ||
            value.isKeyPressed(LogicalKeyboardKey.keyS)) {
          if (direction != Direction.down &&
              direction != Direction.up) {
            direction = Direction.down;
          }
        } else if 
(value.isKeyPressed(LogicalKeyboardKey.arrowLeft) ||
            value.isKeyPressed(LogicalKeyboardKey.keyA)) {
          if (direction != Direction.left &&
              direction != Direction.right) {
            direction = Direction.left;
          }
        } else if (value
                .isKeyPressed(LogicalKeyboardKey.arrowRight) ||
            value.isKeyPressed(LogicalKeyboardKey.keyD)) {
          if (direction != Direction.right &&
              direction != Direction.left) {
            direction = Direction.right;
          }
        }
      } else if ([
        LogicalKeyboardKey.arrowUp,
        LogicalKeyboardKey.keyW,
        LogicalKeyboardKey.arrowDown,
        LogicalKeyboardKey.keyS,
        LogicalKeyboardKey.arrowLeft,
        LogicalKeyboardKey.keyA,
        LogicalKeyboardKey.arrowRight,
        LogicalKeyboardKey.keyD
      ].contains(value.logicalKey)) {
        /// If the game is not started and pressed on the key
        /// this starts the game
startGame();
      }
    },
    child: SizedBox(
      width: 450,
      child: Container(
        decoration: BoxDecoration(border: Border.all(width: 2)),
        constraints: const BoxConstraints(
            maxWidth: 352 + 16,
            minWidth: 352 + 16,
            maxHeight: 560 + 16,
            minHeight: 560 + 16),
        child: Center(
          child: Stack(
            children: [
              ...List.generate(
                  snakeBodys.length,
                  (index) => Visibility(
                        visible: !deathFlicker,
                        replacement: const SizedBox(),
                        child: Positioned(
                            left: snakeBodys[index].offset.dx,
                            top: snakeBodys[index].offset.dy,
                            child: getSnakeBody(snakeBodys[index],
                                index, snakeBodys.length)),
                      )),
              Positioned(
                  left: food.offset.dx,
                  top: food.offset.dy,
                  child: snakeFood),
              if (bigFood.show)
                Positioned(
                    left: bigFood.offset.dx,
                    top: bigFood.offset.dy,
                    child: geekyFood),
            ],
          ),
        ),
      ),
    ),
  ),
)

Perfect! The snake is now able to change directions when the arrow keys are pressed or when we drag. The point of the game is to eat as much food as possible, so we simply display the score using the Text widget.

Combining all of the pieces with the automated movement, arrow keys, touch or mouse drag, food, and score, we now have a fully working snake game. The game is now fully operational!

You can play this game here Live: https://clasicsnakegame.web.app/#/

You find the code here: https://github.com/hemanthkb97/snake_game_clasic

Hire our Development experts.