UIKit Apprentice, Second Edition – Now Updated!

Learn iOS and Swift from scratch. Build four powerful apps—with support for iPad and Dark Mode. Publish apps to the App Store.

Home Flutter Tutorials

How to Create a 2D Snake Game in Flutter

Learn how to use Flutter as a simple game engine by creating a classic 2D Snake Game. Get to know the basics of 2D game graphics and how to control objects.

4.9/5 7 Ratings

Version

  • Dart 2.10, Flutter, Android Studio 4.2

The Flutter framework lets you build apps for Android, iOS, web and even desktop platforms, all using a single codebase. While many big companies use Flutter for their thriving apps, including Google Pay and Alibaba Xianyu, not many are exploring game development with Flutter. That’s just what you’ll do in this tutorial.

Since Flutter is capable of rendering UI at up to 60 FPS, you’ll exploit that capability to build a simple 2D Snake game in Flutter.

Along the way, you’ll learn how to:

  • Use Flutter as a game engine
  • Move objects
  • Control movement
  • Build game UI
  • Add game elements
Note: This article assumes that you know the basics of the Flutter framework including common widgets and their properties. This also assumes that you are comfortable with the Dart programming language and conventions. To learn about the basics of Flutter and Dart, please checkout Dart Apprentice and Flutter Apprentice books.

Now, it’s time to familiarize yourself with the starter project.

Getting Started

Download the starter project by clicking the Download Materials button at the top or bottom of the tutorial.

Unzip the downloaded file and open it with Android Studio 4.1 or later. You can use Visual Studio Code instead, but if you do, you’ll need to tweak some instructions to follow along.

Click Open an existing Android Studio project and choose the starter folder from your unzipped download.

Run the flutter create . command in the starter folder to generate the android and ios folders. Next, download your dependencies by double-clicking pubspec.yaml on the left-side panel, then clicking pub get at the top of your screen. To avoid problems, delete the test folder, which Flutter generated when you executed the flutter create . command.

Finally, build and run to see this:

Starter project on iOS Simulator

The starter project sets up the playing field. You’ll add UI elements to it as you build the game. The starter project also provides several useful utility methods so you can focus on the bigger picture: writing the game. Here are the pre-built functions you’ll find in the starter project, along with how to use them:

  • roundToNearestTens(int num): Rounds off the passed integer argument to the nearest step value. This allows you to get the exact next position that’s step units away from the current position on an imaginary grid.
  • getRandomPositionWithinRange(): Generates a random position on the screen within the bounds of the play area. You’ll use this function to spawn a new snake and food for the snake.
  • showGameOverDialog(): Displays a styled dialog pop-up when the Snake collides with any of the boundaries of the play area. This dialog displays the user’s score and a button to restart the game.
  • getRandomDirection([String type]): Randomly returns one of the four directions: up, down, left or right. You primarily use this function to move the Snake in a random direction when it spawns. It optionally takes an argument that specifies whether you want the random direction it returns to be horizontal or vertical.
  • getRandomPositionWithinRange(): Returns a random position on the screen within the play area. It ensures that the random position lies on the grid along which the snake moves.
  • getControls(): Implements ControlPanel, a widget that displays four circular buttons on the screen to control the movement of the snake.
  • getPlayAreaBorder(): Draws a border along the edge of the screen to represent the play area where the screen moves.

Using Flutter as a Game Engine

A game engine is a suite of tools and services that helps game developers build games. They handle a lot of different things like graphics, sounds, artificial intelligence, user input, networking, and much more. Flutter can handle most of these things, as well. As a Flutter developer, you have access to a whole world of Dart plugins. Plus, if one doesn’t exist, you can always code it yourself.

To start with Flutter as a game development engine, you’ll use the rendering capabilities of the framework to animate game objects like the snake, food, and game score. In this case, you’ll use a timer to regenerate the UI 60 times per second and display a snake on the screen. By rendering the snake at up to 60 FPS while changing its position, you can achieve a pretty smooth moving animation effect.

Game engines like Unity work in a similar manner. The difference here is that pushing the limits of the framework too much could end up in an app that needs a lot of resources. However, while testing this game, Flutter performed amazingly well without heating the device at all.

Drawing the Snake

Your first step is to create the snake. You’ll start with the Piece widget in the starter project, which renders a colored circle on the screen. With this widget, you’ll draw the snake, piece-by-piece, as well as the snake food.

Before you draw the snake, however, it’s worth taking a moment to understand the basics of 2D rendering with Flutter.

The Basics of 2D Rendering

To render anything that’s constantly changing its position on the screen, you’ll need to use widgets like Stack and Positioned. Given the x and y coordinates, these widgets can place child widgets anywhere on the screen.

The most important thing to keep in mind is that you’re working on a mobile app, which needs to run on devices with all sorts of screen sizes and aspect ratios. That means you can’t hard code any of the dimensions of the game field. Instead, you’ll use MediaQuery to get the width and height of the user’s screen, then calculate position based on these values.

For example, here’s a simple code snippet that declares variables, then assigns the screen’s width and height to them.

final screenSize = MediaQuery.of(context).size;
final screenWidth = screenSize.width;
final screenHeight = screenSize.height;

In the code snippet above, the MediaQuery inherited widget is used to get the current device’s screen’s width and height. This requires the content to be passed and therefore, we have this code right inside the build() itself.

Now that you know how you’ll place objects on the screen, it’s time to draw that snake.

Creating the Snake

You’ll use Piece to create the snake. First, you’ll create List based on the position of the snake on the screen. Then, you’ll store the positions of all the pieces that make up the snake in a List called positions. You’ll do this using getPieces(), which reads positions and returns a List of Pieces.

Start by replacing getPieces(), located in lib/game.dart, with:

 List<Piece> getPieces() {
    final pieces = <Piece>[];
    draw();
    drawFood();

    // 1
    for (var i = 0; i < length; ++i) {
      // 2
      if (i >= positions.length) {
        continue;
      }

      // 3
      pieces.add(
        Piece(
          posX: positions[i].dx.toInt(),
          posY: positions[i].dy.toInt(),
          // 4
          size: step,
          color: Colors.red,
        ),
      );
    }

    return pieces;
}

Here’s what’s going on in the code above:

  1. You use a for loop that runs until it covers the entire length of the snake.
  2. The if block inside the for loop handles an edge case when the length of the snake doesn’t match the length of the positions list. This happens when the length of the snake increases after it eats some food.
  3. For each iteration, it creates a Piece with the correct position and adds it to the pieces list it returns.
  4. Along with the position, you also pass size and color to Piece. The size is step, in this case, which ensures that the Snake moves along a grid where each grid cell has the size step. The color value is a personal preference. Feel free to use your own colors.

Save the file and let the app hot reload. So far, nothing happens and you will not notice any changes in the UI.

Starter project on iOS Simulator

Filling the Positions List

You need to implement draw() to actually fill the positions list. So replace draw() in the same file with the following:

  void draw() async {
    // 1
    if (positions.length == 0) {
      positions.add(getRandomPositionWithinRange());
    }

    // 2
    while (length > positions.length) {
      positions.add(positions[positions.length - 1]);
    }

    // 3
    for (var i = positions.length - 1; i > 0; i--) {
      positions[i] = positions[i - 1];
    }

    // 4
    positions[0] = await getNextPosition(positions[0]);
  }

Here’s how the function above works:

  1. If positions is empty, getRandomPositionWithinRange() generates a random position and starts the process.
  2. If the snake has just eaten the food, its length increases. The while loop adds a new position to positions so that length and positions are always in sync.
  3. It checks positions‘s length and shifts each position. This creates the illusion that the snake is moving.
  4. Finally, getNextPosition() moves the first piece, the head of the snake, to a new position.

The last thing you need to do here is to implementat getNextPosition().

Moving the Snake to the Next Position

Add the following code to the function in lib/game.dart:

  Future<Offset> getNextPosition(Offset position) async {
    Offset nextPosition;

    if (direction == Direction.right) {
      nextPosition = Offset(position.dx + step, position.dy);
    } else if (direction == Direction.left) {
      nextPosition = Offset(position.dx - step, position.dy);
    } else if (direction == Direction.up) {
      nextPosition = Offset(position.dx, position.dy - step);
    } else if (direction == Direction.down) {
      nextPosition = Offset(position.dx, position.dy + step);
    }

    return nextPosition;
  }

Here’s what the code above does:

  1. Creates a new position for the object based on the object’s current position and the value of its direction. Changing the direction causes the object to move in a different direction. You’ll use control buttons to do this later.
  2. Increases the value of the x-coordinate if the direction is set to right and decreases the value if the direction is set to left.
  3. Similarly, increases the value of the y-coordinate if the direction is set to up decreases it if the direction is set to down.

Finally, change [] to getPieces() in the inner Stack():

  return Scaffold(
    body: Container(
      color: Color(0XFFF5BB00),
      child: Stack(
        children: [
          Stack(
            children: getPieces(),
          ),
        ],
      ),
    ),
  );

In the code snippet above, we are just adding the getPieces() method that returns a widget to the Stack so we can see some UI on the screen. Note that if you do not add the widget to build(), nothing will change on the screen.

Save everything and restart the app. You’ll see:

Rendering of a two-piece snake

You can see a snake… that does nothing. That’s because you haven’t added anything to rebuild the UI. However, save the files again and again and let hot reload do its job and you’ll see the snake move.

Adding Movement and Speed

Now, all you need to make the snake move is a way to rebuild the UI. Every time build is called, you need to calculate the new positions and render the new list of Pieces onscreen. To do this, you’ll use Timer.

Add the following definition to changeSpeed() in lib/game.dart:

  void changeSpeed() {
    if (timer != null && timer.isActive) timer.cancel();

    timer = Timer.periodic(Duration(milliseconds: 200 ~/ speed), (timer) {
      setState(() {});
    });
  }

The code above simply resets the timer with a duration that factors in speed. You control speed and increase it every time the snake eats the food. Finally, on every tick of the timer, you call setState(), which rebuilds the whole UI. This happens at a rate you control using speed.

Next, invoke changeSpeed() from within restart() in the same file:

  void restart() {
    changeSpeed();
  }

This reinitializes timer every time the user restarts the game.

Save all the files and restart the app. Now, the snake moves in a random direction every time you restart the app.

Snake moving

Adding Controls

Now that you have a moving snake, you need to add a few more knobs and dials so the user can control the snake’s direction and speed. Keep the following things in mind:

  • You’ll control the movement and the direction using ControlPanel.
  • The speed must increase every time the snake eats food.
  • restart() resets the speed, length and direction when the snake collides with the bounding box.
  • After a collision, you’ll display a Game Over alert to the user.

Changing the Direction

ControlPanel has everything you need to give the user control over the snake’s direction. It consists of four circular buttons, where each button changes the direction of the snake’s movement. The widget lives inside lib/control_panel.dart. Feel free to dig in and look at the implementation.

Now, add the following code to getControls() in lib/game.dart:

  Widget getControls() {
    return ControlPanel( // 1
      onTapped: (Direction newDirection) { // 2
        direction = newDirection; // 3
      },
    );
  }

In the code snippet above:

  1. We are using the ControlPanel widget which is already created in the starter project for you. The ControlPanel widget renders 4 buttons that you will use to control the snake’s movements.
  2. We are also using the onTapped method which recieves the new direction for the snake to move in as an argument.
  3. We update the direction variable with the new direction newDirection. This will cause the snake to change direction.

Also, add the following import at the top of the document:

  import 'control_panel.dart';

Next, add getControls() as the second child of the outer Stack in build():

@override
Widget build(BuildContext context) {
  // ...
  return Scaffold(
    body: Container(
      color: Color(0XFFF5BB00),
      child: Stack(
        children: [
          Stack(
            children: getPieces(),
          ),
          getControls(),
        ],
      ),
    ),
  );
}

The code above adds the widgets returned by the getControls method to the UI on the screen within the Stack but above the rest of the UI.

Save the files and restart the app. Now, you’ll see a set of buttons that control the snake’s direction.

The ControlPanel widget in action

Tapping the buttons changes the snake’s direction. By now, the game works on a simple level, but you still need to add something the player should strive for. Just moving a colorful snake around on the screen isn’t a lot of fun, right? So your next step is to give the snake some food to eat that speeds it up.

Eating Food and Increasing Speed

To render the food onscreen, you’ll use Piece again, but you’ll change its color. Keep in mind that you don’t want the food to show up at just any arbitrary position. Instead, it should always render at a random location within the play area and it should always lie on the grid that the snake moves along.

Now, you’ll implement drawFood() to render the food Piece on the screen. Add the following code to drawFood() in the same file:

  void drawFood() {

    // 1
    if (foodPosition == null) {
      foodPosition = getRandomPositionWithinRange();
    }

    // 2
    food = Piece(
      posX: foodPosition.dx.toInt(),
      posY: foodPosition.dy.toInt(),
      size: step,
      color: Color(0XFF8EA604),
      isAnimated: true,
    );
  }

Here is what is happening above.

  1. The code above creates a Piece and stores it inside food.
  2. It stores the position of food inside an Offset object, foodPosition. Initially, this is null, so you use getRandomPositionWithinRange() to render the food randomly anywhere on the screen within the play area.

Displaying the Food Onscreen

Next, you need to add food to build within Stack to render it onscreen.

@override
Widget build(BuildContext context) {
  //...
  return Scaffold(
    body: Container(
      color: Color(0XFFF5BB00),
      child: Stack(
        children: [
          Stack(
            children: getPieces(),
          ),
          getControls(),
          food,
        ],
      ),
    ),
  );
}

Save all the files and restart the app to see the changes in action. Once the app restarts, you’ll see the green-colored food on the screen.

Food rendered on the screen

However, if you try to navigate the snake to the food, nothing happens — it just runs over the food icon. That’s because you haven’t done anything to make the snake eat the food. You’ll fix that next.

Consuming and Regenerating the Food

Now, you need to check if the snake and food are at the same coordinates in the 2D space. If they are, you’ll make some changes to the snake and render some new food at a new location.

Add the following code to drawFood(), right after the first if block ends:

  void drawFood() {

    // ...

    if (foodPosition == positions[0]) {
      length++;
      speed = speed + 0.25;
      score = score + 5;
      changeSpeed();

      foodPosition = getRandomPositionWithinRange();
    }

    // ...
  }

The code above simply checks if you the position you stored in foodPosition and the position of the snake’s first Piece widget are the same. If they match, you increase length by 1, speed by 0.25 and score by 5. Then you call changeSpeed(), which reinitializes timer using the new settings.

Finally, you update foodPosition with a new random position on the screen, thereby rendering a new food Piece.

Save the files and restart the app to let the changes take effect.

Eating the food

The snake can now eat the food. When it does, its length increases considerably.

Detecting Collisions and Showing the Game Over Dialog

As of now, the snake can freely move around — which causes a problem. There’s nothing preventing the snake from going outside the rectangular play area. You need to limit the snake’s movement to stay within the play area you’ve defined on the screen.

First, render that play area using getPlayAreaBorder(), which adds an outline to the play area. Simply add getPlayAreaBorder() to the outer Stack in build().

@override
Widget build(BuildContext context) {
  //...
  return Scaffold(
      body: Container(
        color: Color(0XFFF5BB00),
        child: Stack(
          children: [
            getPlayAreaBorder(),
            Stack(
              children: getPieces(),
            ),
            getControls(),
            food,
          ],
        ),
      ),
    );
}

The above code adds the food widget to the Stack in build() so now it will be rendered on the screen.

Next, add the following code to detectCollision():

  bool detectCollision(Offset position) {

    if (position.dx >= upperBoundX && direction == Direction.right) {
      return true;
    } else if (position.dx <= lowerBoundX && direction == Direction.left) {
      return true;
    } else if (position.dy >= upperBoundY && direction == Direction.down) {
      return true;
    } else if (position.dy <= lowerBoundY && direction == Direction.up) {
      return true;
    }

    return false;
  }

The function above checks if the snake has reached any of the four boundaries. If it has, then it returns true, otherwise, it returns false. It uses the lowerBoundX, upperBoundX, lowerBoundY and upperBoundY variables to check if the snake is still within the play area or not.

Next, you need to use detectCollision() inside getNextPosition() to check for a collision each time you generate the snake's next position. Add the following code to getNextPosition(), right after the declaration of nextPosition:

Future<Offset> getNextPosition(Offset position) async {
  //...
  if (detectCollision(position) == true) {
      if (timer != null && timer.isActive) timer.cancel();
      await Future.delayed(
          Duration(milliseconds: 500), () => showGameOverDialog());
      return position;
    }
  //...
}

The code above checks for a collision. If the snake collides with the bounding box, the code cancels the timer and displays the Game Over dialog to the user using showGameOverDialog().

The Game Over dialog

Save all the files and hot reload the app.

This time, if the snake touches the surrounding bounding box, you'll immediately see a dialog that informs the user that the game is over and displays their score. Tap on the Restart button in the dialog to dismiss the dialog and restart the game. You'll do that next.

Adding Some Finishing Touches

The game is starting to take shape. Your next steps are to write code to restart the game, then add a score to give the game a competitive element.

Restarting the Game

Next, you'll work on making the game restart when the user taps the Restart button. Replace the existing restart in lib/game.dart with:

  void restart() {

    score = 0;
    length = 5;
    positions = [];
    direction = getRandomDirection();
    speed = 1;

    changeSpeed();
  }

The code above simply resets everything to their initial values. It also clears positions so the snake loses its length and respawns from scratch.

Displaying the Score

Next, you need to add the code to display the score on the top-right corner of the screen. To do that, implement getScore():

  Widget getScore() {
    return Positioned(
      top: 50.0,
      right: 40.0,
      child: Text(
        "Score: " + score.toString(),
        style: TextStyle(fontSize: 24.0),
      ),
    );
  }

Add getScore() to build() as the last child of the outer Stack right after food within the Stack. Finally, your build() should look like this:

@override
Widget build(BuildContext context) {
  //...
  return Scaffold(
    body: Container(
      color: Color(0XFFF5BB00),
      child: Stack(
        children: [
          getPlayAreaBorder(),
          Stack(
            children: getPieces(),
          ),
          getControls(),
          food,
          getScore(),
        ],
      ),
    ),
  );
}

Save all the files and restart the app.

The final Snake Game with score

Now, you should see the score update in real-time.

Yay! You did it. Now it's time to build the app and share it with your friends to show off your developer skills.

Where to Go From Here?

Download the complete project files by clicking the Download Materials button at the top or bottom of the tutorial. If you want to play around with the code some more, here are a few things you can add to make it more interesting and fun:

  • Add collision detection so the game ends if the snake collides with itself. Since you have the position of each and every Piece that makes up the snake, it isn't hard to determine whether the snake has run into itself.
  • Restrict the snake's movement so it can't double back in the opposite direction of the one it's currently heading in.
  • Allow the users to choose if they really want to challenge themselves. If you want to level up the game to hardcore mode, you need to make the speed increases faster and the score increases slower. This makes the game harder.
  • You can also keep track of all users' scores. This adds a competitive element to the game by telling the players the scores of all the other players. Who doesn't want to be the top-scoring player?
  • Finally, adding sounds to the game will make it much more fun.

Here are a couple of articles that will be helpful in learning more about widgets, drawing and animations in Flutter.

We hope you enjoyed this tutorial. If you have any questions or comments, please join the forum discussion below!

Average Rating

4.9/5

Add a rating for this content

7 ratings

More like this

Contributors

Comments