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.
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 co-ordinates, the length of this list will determine the length of the snake, we can define each coordinate as one snake’s body, 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
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.
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 variablesinitBoard() {
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 thesnake moves through/// and comes from the other sidesetState(() {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 columnif (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 matrixsnakeBodys.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 startedstart = true;// Reset BoardinitBoard();// Cancel Timer if the old timer is runningif (timer != null) {timer!.cancel();timer == null;}// Assign new timertimer = 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 positionupdateSnake();// after the snake’s new position is updated get the currentpositions 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
This article was originally written by Hemanth Kumar B. He is a Software engineer at Geekyants, currently working as a Flutter developer and constantly learning and exploring new technologies.