Build Tetris using React Native and Expo

Lesson 5 of 8

Locking Pieces and Clearing Lines

When a falling piece can't move down any further, it needs to permanently lock into the board, and any completely filled rows need to disappear. This is the heart of Tetris's core loop.

Locking a piece into the board

function lockPiece(board: Matrix, piece: ActivePiece): Matrix {
  const newBoard = board.map((row) => [...row]); // copy, never mutate the board in place
  const colorId = SHAPE_COLORS[piece.shapeName];

  for (let r = 0; r < piece.shape.length; r++) {
    for (let c = 0; c < piece.shape[r].length; c++) {
      if (!piece.shape[r][c]) continue;
      const boardRow = piece.row + r;
      const boardCol = piece.col + c;
      if (boardRow >= 0) newBoard[boardRow][boardCol] = colorId;
    }
  }

  return newBoard;
}

board.map((row) => [...row]) makes a deep-enough copy, a new outer array and new inner row arrays, so writing into newBoard never mutates the board that was passed in. This matters for the same reason createEmptyBoard's Array.from mattered: React needs a genuinely new array reference to know something changed and re-render.

Finding and clearing full rows

function clearFullRows(board: Matrix): { board: Matrix; linesCleared: number } {
  const remainingRows = board.filter((row) => row.some((cell) => cell === 0));
  const linesCleared = BOARD_HEIGHT - remainingRows.length;

  const emptyRows = Array.from({ length: linesCleared }, () => Array(BOARD_WIDTH).fill(0));
  return { board: [...emptyRows, ...remainingRows], linesCleared };
}

row.some((cell) => cell === 0) is true for any row with at least one empty cell, so board.filter(...) keeps only the rows that are not completely full, exactly the rows that survive a line clear. Prepending fresh empty rows ([...emptyRows, ...remainingRows]) at the top is what makes everything above a cleared line visually fall down, the surviving rows just end up lower in the new array.

Wiring it into the tick

function handleTick(board: Matrix, piece: ActivePiece) {
  if (!hasCollision(board, piece, piece.row + 1, piece.col)) {
    return { board, piece: { ...piece, row: piece.row + 1 }, linesCleared: 0 };
  }

  // can't fall further: lock it, clear lines, spawn the next piece
  const locked = lockPiece(board, piece);
  const { board: clearedBoard, linesCleared } = clearFullRows(locked);
  const nextPiece = spawnPiece(randomShapeName());
  return { board: clearedBoard, piece: nextPiece, linesCleared };
}

Every tick asks one question: can the piece move down one more row? If yes, just move it, business as usual. If no, that's the signal the piece has landed, lock it, clear whatever rows are now full, and spawn a fresh piece to keep the game going.

NOTE

randomShapeName() here just needs to return one of 'I' | 'O' | 'T' | 'S' | 'Z' | 'J' | 'L', a simple Math.random()-based picker works, but produces streaky, unfair randomness (you could see the same piece five times in a row), the "Next Piece and the 7-Bag" lesson replaces it with the fairer system real Tetris games use.

📝 Locking and Line Clears Quiz

Passing score: 70%
  1. 1.What does row.some((cell) => cell === 0) identify?

  2. 2.lockPiece() mutates the board array that was passed into it directly, rather than returning a copy.

  3. 3.Prepending fresh empty rows at the top of the board (rather than the bottom) is what makes rows above a clear visually ____ down.