Blog.

Building a Tetris game in Flutter

Cover Image for Building a Tetris game in Flutter

Introduction

I just came up with the idea of building game Tetris instantly came to mind, it seemed like the perfect playground to explore game development within a UI centric framework. It offered a interesting challenge to demonstrate how Flutter can be used beyond traditional app UIs to create an engaging, interactive game.

This post will walk you through the entire process: how I structured the grid, made the tetrominoes fall, handled collisions and rotations, and finally created the game-over logic.

Tetris game.

Implementation

So, only an idea is not enough and the first thought in my mind was how the game will look and then I started my work to build the User interface of the application. A Tetris game requires multiple blocks, both horizontal and vertical so the perfect widget that came into my mind was the GridView Builder.

Building the Grid

The Tetris board consists of blocks stacked in rows and columns. I defined the grid dimensions like

int rowLength = 10;
int colLength = 15;

Using this, I rendered rowLength × colLength = 150 cell. Each cell is represented by a widget named Pixel, which is just a styled Container.

class Pixel extends StatelessWidget {
  final Color color;
  const Pixel({super.key, required this.color});

  @override
  Widget build(BuildContext context) {
    return Container(
      decoration: BoxDecoration(
        color: color,
        borderRadius: BorderRadius.circular(5),
      ),
      margin: const EdgeInsets.all(1),
    );
  }
}

And used it inside GridView.builder.

Creating the Game Board

A visual grid alone isn’t enough - we need to track what’s happening in each cell. For that, I created a 2D list, which acts as the logical representation of the board.

List<List<Tetromino?>> gameBoard = 
    List.generate(colLength, (_) => List.generate(rowLength, (_) => null));

Each cell in the list can hold a Tetromino type. Here’s the enum I used to define the 7 Tetris shapes.

enum Tetromino { L, J, I, O, S, Z, T }

Defining and Initializing Pieces

Each Tetris piece has a specific shape and a set of positions. I created a Piece class to manage the behavior and structure of each shape. The key part is a list of indexes called position, which tracks where each piece currently is in the grid.

List<int> position = [];

When a new piece is created, I initialized its shape using a custom function. I used negative indices so the piece starts above the visible grid and appears to "fall" down.

void initializePiece() {
    switch (type) {
      case Tetromino.L:
        position = [-26, -16, -6, -5];
        break;

      case Tetromino.I:
        position = [-4, -5, -6, -7];
        break;

      case Tetromino.J:
        position = [-25, -15, -5, -6];
        break;

      case Tetromino.O:
        position = [-15, -16, -5, -6];
        break;

      case Tetromino.S:
        position = [-15, -14, -6, -5];
        break;
      case Tetromino.T:
        position = [-26, -16, -6, -15];
        break;

      case Tetromino.Z:
        position = [-17, -16, -6, -5];
        break;
      default:
    }
  }

Coloring Each Shape

Each shape needs to be visually distinct, so I mapped each type of tetromino to a color.

const Map<Tetromino, Color> tetrominoColors = {
  Tetromino.L: Color(0xFFFFA500),
  Tetromino.J: Color.fromARGB(255, 0, 102, 255),
  Tetromino.I: Color.fromARGB(255, 242, 0, 255),
  Tetromino.O: Color(0xFFFFFF00),
  Tetromino.S: Color(0xFF008000),
  Tetromino.Z: Color(0xFFFF0000),
  Tetromino.T: Color.fromARGB(255, 144, 0, 255),
};

These colors are applied dynamically when rendering each grid cell.

itemBuilder: (context, index) {
  int col = index % rowLength;
  int row = (index / rowLength).floor();

  if (currentpiece.position.contains(index)) {
    return Pixel(color: currentpiece.color);
  } else if (gameBoard[row][col] != null) {
    return Pixel(color: tetrominoColors[gameBoard[row][col]]!);
  } else {
    return Pixel(color: Colors.grey[900]!);
  }
}

Now, the base of the application is set.

Movement and Collision Detection

I implemented a movingPiece() method. It updates the position list based on a direction. Here Direction is just an enum that I created for Initializing the possible directions a piece will require which are obviously right, left, and down.

enum Directions { Left, Right, Down }

void movingPiece(Directions direction) {
  for (int i = 0; i < position.length; i++) {
    if (direction == Directions.Down) position[i] += rowLength;
    if (direction == Directions.Left) position[i] -= 1;
    if (direction == Directions.Right) position[i] += 1;
  }
}

Now, Just for review, what have I done so far?

First of all, created grid then gameboard to help us get the exact position of each piece. After that I want piece to fall into our grid so for that, created the logic to implement piece into pixel and generated different colors for different pieces then implemented the moving functionality of our pieces with the moving function.

Now need to ensure a piece doesn’t move through walls or other pieces. So I created a collision detection method. Before moving a piece, need to check for collisions

bool checkCollision(Directions direction) {
  for (int i = 0; i < currentpiece.position.length; i++) {
    int col = currentpiece.position[i] % rowLength;
    int row = (currentpiece.position[i] / rowLength).floor();

    if (direction == Directions.Left) col--; 
    if (direction == Directions.Right) col++;
    if (direction == Directions.Down) row++;

    if (col < 0 || col >= rowLength || row >= colLength) return true;
    if (row >= 0 && gameBoard[row][col] != null) return true;
  }
  return false;
}

Landing and Creating New Pieces

Once a falling piece can’t move down further, it must land and become part of the board:

void checkLanding() {
  if (checkCollision(Directions.Down)) {
    for (int i = 0; i < currentpiece.position.length; i++) {
      int row = (currentpiece.position[i] / rowLength).floor();
      int col = currentpiece.position[i] % rowLength;
      if (row >= 0) gameBoard[row][col] = currentpiece.type;
    }
    createNewPiece();
  }
}

A new piece is create randomly

void createNewPiece() {
  final rand = Random();
  final randomType = Tetromino.values[rand.nextInt(Tetromino.values.length)];
  currentpiece = Piece(type: randomType);
  currentpiece.initializePiece();

  if (isGameOver()) gameOver = true;
}

Game Loop

To keep the game running, I created a loop that moves the piece down every half-second:

void startGame() {
  currentpiece.initializePiece();
  gameLoop(const Duration(milliseconds: 500));
}

void gameLoop(Duration frameRate) {
  Timer.periodic(frameRate, (timer) {
    setState(() {
      clearLines();
      checkLanding();

      if (gameOver) {
        timer.cancel();
        showGameOverDialog();
      }

      currentpiece.movingPiece(Directions.Down);
    });
  });
}

Now I have implemented the creation of the piece, the moving of the piece, the landing of the piece, and also a logic to avoid a collision. Everything is set now and left with just a couple of things which are: Moving the piece to the left, and right, and rotating it. and implementing the logic when the game is over and when a user scores a point.

Moving and Rotating Pieces

Players can move pieces left/right or rotate them:

void moveLeft() {
  if (!checkCollision(Directions.Left)) {
    setState(() => currentpiece.movingPiece(Directions.Left));
  }
}

void moveRight() {
  if (!checkCollision(Directions.Right)) {
    setState(() => currentpiece.movingPiece(Directions.Right));
  }
}

void rotatePiece() {
  setState(() => currentpiece.rotatePiece());
}

Each tetromino (except the square O piece) can rotate in up to four directions, known as rotation states. The goal of rotation is to calculate a new set of block positions relative to the current position of the shape.

To handle this, I added a rotationState variable to the Piece class, which tracks which orientation the current piece is in

int rotationState = 0;

The rotation logic recalculates the position based on this state and attempts to update the shape's layout if the new positions are valid.

  void rotatePiece(){
    List<int> newPosition=[];
    switch(type){
      case TetrominoShapes.L:
        switch(rotationState){
          case 0:
            {
              newPosition=[
                position[1]-rowLength,
                position[1],
                position[1]+rowLength,
                position[1]+rowLength+1
              ];
              if(piecePositionIsValid(newPosition)){
                position=newPosition;
                rotationState=(rotationState+1 )%4;
              }}
            break;

          case 1:
            {newPosition=[
              position[1]-1,
              position[1],
              position[1]+1 ,
              position[1]+rowLength-1
            ];
            if(piecePositionIsValid(newPosition)){
              position=newPosition;
              rotationState=(rotationState+1 )%4;
            }}
            break;

          case 2:
            {newPosition=[
              position[1]+rowLength,
              position[1],
              position[1]-rowLength,
              position[1]-rowLength-1
            ];
            if(piecePositionIsValid(newPosition)){
              position=newPosition;
              rotationState=(rotationState+1 )%4;
            }
            }
            break;

          case 3:
            {newPosition=[
              position[1] - rowLength + 1,
              position[1],
              position[1]+1,
              position[1]-1
            ];
            if(piecePositionIsValid(newPosition)){
              position=newPosition;
              rotationState= 0;
            }
            }
            break;
        }
        break;

    

    // Similar logic can be applied to other shapes like T, J, Z, S, etc.
  }
}

This function calculates the new position based on the middle block position[1] acting as the rotation pivot. It ensures the rotated piece won’t go out of bounds or collide with existing pieces using a function called piecePositionIsValid().

Scoring and Game Over

Every time a full row is detected, remove it and increment the score.

int currentScore = 0;

void clearLines() {
  for (int row = colLength - 1; row >= 0; row--) {
    if (gameBoard[row].every((block) => block != null)) {
      for (int r = row; r > 0; r--) {
        gameBoard[r] = List.from(gameBoard[r - 1]);
      }
      gameBoard[0] = List.generate(rowLength, (_) => null);
      currentScore++;
    }
  }
}

the game ends if any block appears in the top row:

bool isGameOver() {
  return gameBoard[0].any((block) => block != null);
}

To reset the game:

void resetGame() {
  gameBoard = List.generate(colLength, (_) => List.generate(rowLength, (_) => null));
  gameOver = false;
  currentScore = 0;
  createNewPiece();
  startGame();
}

That’s how I built a classic Tetris game using Flutter with zero external dependencies. From rendering the board to managing tetrominoes, collisions, scoring, and game-over logic everything was achieved using native Dart and Flutter features.

Thanks for reading!