Build Tetris using React Native and Expo

Lesson 8 of 8

Final Project: Complete Your Tetris Game

Every piece from this course now exists on its own: the board, all 7 tetrominoes with rotation, gravity, collision, touch controls, locking, line-clearing, scoring, levels, game over, and a fair 7-bag piece queue. This final project assembles all of it into one complete, playable Tetris screen.

Requirements

Your finished app/tetris.tsx screen should satisfy every one of these:

  1. Creates a 10x20 board using createEmptyBoard() and renders it with the nested-View grid pattern.
  2. Spawns tetrominoes from the 7-bag queue (usePieceQueue), not plain Math.random(), and shows a "Next" preview of the upcoming piece.
  3. Runs a game loop (useGameLoop) that moves the active piece down automatically, at a speed that increases with the level.
  4. Provides on-screen touch controls for move left, move right, rotate, and soft drop, each going through hasCollision() before being applied.
  5. Locks a piece into the board when it can no longer fall (lockPiece), clears any completed rows (clearFullRows), and updates score/level based on lines cleared.
  6. Detects game over when a freshly spawned piece immediately collides, stops the game loop, and shows a game over message with the final score.

Bringing every earlier lesson's pieces together into one screen (state, game loop, controls, and rendering) is the whole project:

import { useCallback, useState } from 'react';
import { View, Text, StyleSheet } from 'react-native';

export default function TetrisScreen() {
  const [board, setBoard] = useState<Matrix>(createEmptyBoard);
  const [piece, setPiece] = useState<ActivePiece>(() => spawnPiece('T'));
  const [score, setScore] = useState(0);
  const [totalLines, setTotalLines] = useState(0);
  const [gameOver, setGameOver] = useState(false);
  const { next, takeNext } = usePieceQueue();

  const level = levelForLines(totalLines);

  const tick = useCallback(() => {
    if (gameOver) return;
    if (!hasCollision(board, piece, piece.row + 1, piece.col)) {
      setPiece((p) => ({ ...p, row: p.row + 1 }));
      return;
    }
    const locked = lockPiece(board, piece);
    const { board: cleared, linesCleared } = clearFullRows(locked);
    const nextPiece = spawnPiece(takeNext());

    if (isGameOver(cleared, nextPiece)) {
      setBoard(cleared);
      setGameOver(true);
      return;
    }

    setBoard(cleared);
    setPiece(nextPiece);
    setScore((s) => s + scoreForLines(linesCleared, level));
    setTotalLines((t) => t + linesCleared);
  }, [board, piece, gameOver, level, takeNext]);

  useGameLoop(speedForLevel(level), tick);

  return (
    <View style={styles.screen}>
      <Text style={styles.score}>Score: {score}</Text>
      <Board board={renderPieceOnBoard(board, piece)} />
      <NextPreview shapeName={next} />
      <Controls
        onLeft={() => tryMove(0, -1)}
        onRight={() => tryMove(0, 1)}
        onRotate={tryRotate}
        onDrop={() => tryMove(1, 0)}
      />
      {gameOver && <Text style={styles.gameOver}>Game Over! Final score: {score}</Text>}
    </View>
  );
}

const styles = StyleSheet.create({
  screen: { flex: 1, alignItems: 'center', paddingTop: 48, backgroundColor: '#0f172a' },
  score: { color: '#fff', fontSize: 20, marginBottom: 12 },
  gameOver: { color: '#f87171', fontSize: 18, marginTop: 16 },
});

renderPieceOnBoard(board, piece) (not shown, worth writing yourself) should return a copy of board with the active piece's cells overlaid on top, so the falling piece is visible before it locks, without mutating the real board state.

Stretch goals

  • Add a hard drop: instantly move the piece straight down (loop tryMove(1, 0) until it would collide) instead of waiting for gravity, common in modern Tetris as a fifth control.
  • Add a ghost piece: a faint outline showing where the current piece would land if hard-dropped, computed the same way as the hard drop, but only rendered, not actually moved.
  • Add a hold piece: let the player swap the active piece into a "held" slot once per drop, swapping it back in later.
  • Replace the on-screen buttons with swipe gestures using react-native-gesture-handler, swipe left/right to move, swipe down for soft drop, tap to rotate.
  • Persist the high score locally with @react-native-async-storage/async-storage so it survives closing the app.
  • Add sound effects for line clears and game over with expo-av.

Submit a link to your finished project (a repo or gist) below, an instructor will review it before you can mark this lesson complete.

This lesson ends in a project. Build it on your own machine, there's nowhere to submit it here, but the brief above is everything you need.