Build Tetris using React Native and Expo

Lesson 3 of 8

The Game Loop: Gravity and Collision

A static board and a rotatable shape aren't a game yet, they need to actually fall, and stop falling when they hit something. This lesson builds both.

Piece state

interface ActivePiece {
  shape: Matrix;
  shapeName: string;
  row: number;
  col: number;
}

function spawnPiece(shapeName: string): ActivePiece {
  const shape = SHAPES[shapeName];
  return {
    shape,
    shapeName,
    row: 0,
    col: Math.floor((BOARD_WIDTH - shape[0].length) / 2),
  };
}

row/col track the piece's top-left corner on the board, everything about "where the piece is" lives in these two numbers plus its (possibly rotated) shape matrix. Centering the spawn column keeps every piece appearing in the middle of the board regardless of its width.

Collision detection

function hasCollision(board: Matrix, piece: ActivePiece, row: number, col: number): boolean {
  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; // empty cell within the piece's bounding box

      const boardRow = row + r;
      const boardCol = col + c;
      const outOfBounds = boardCol < 0 || boardCol >= BOARD_WIDTH || boardRow >= BOARD_HEIGHT;
      const collidesWithLocked = boardRow >= 0 && board[boardRow][boardCol] !== 0;

      if (outOfBounds || collidesWithLocked) return true;
    }
  }
  return false;
}

This checks a hypothetical position (row, col), not necessarily the piece's current one, that's deliberate: every move (left, right, rotate, down) works the same way, compute where the piece would end up, ask hasCollision whether that's legal, and only commit the move if it isn't. boardRow >= 0 guards against checking above the board (pieces spawn partially off-screen while rotating near the top), where there's no board row to safely index into.

The falling tick

import { useEffect, useState } from 'react';

function useGameLoop(speedMs: number, onTick: () => void) {
  useEffect(() => {
    const interval = setInterval(onTick, speedMs);
    return () => clearInterval(interval);
  }, [speedMs, onTick]);
}

setInterval calls onTick (which will move the active piece down by one row) every speedMs milliseconds, this is Tetris's gravity. Returning clearInterval from useEffect is essential, without it, every re-render (or speed change) would start a new interval on top of the old one, stacking up multiple ticks firing simultaneously.

WARNING

onTick needs to be stable across renders (wrapped in useCallback, covered in the next lesson) or this effect re-runs (clearing and restarting the interval) far more often than intended, since [speedMs, onTick] includes it as a dependency.

📝 Game Loop and Collision Quiz

Passing score: 70%
  1. 1.Why does hasCollision() check a hypothetical (row, col) instead of only the piece's current position?

  2. 2.Returning clearInterval from a useEffect cleanup function prevents multiple overlapping intervals from stacking up.

  3. 3.The row/col fields on ActivePiece track the position of the shape's ____-____ corner on the board.