Build Tetris using React Native and Expo

Lesson 4 of 8

Touch Controls: Move, Rotate, and Drop

With gravity and collision working, this lesson wires up the controls a player actually taps: move left/right, rotate, and drop.

Move and rotate handlers

function useTetris() {
  const [board] = useState<Matrix>(createEmptyBoard);
  const [piece, setPiece] = useState<ActivePiece>(() => spawnPiece('T'));

  function tryMove(deltaRow: number, deltaCol: number) {
    setPiece((current) => {
      const newRow = current.row + deltaRow;
      const newCol = current.col + deltaCol;
      if (hasCollision(board, current, newRow, newCol)) return current; // illegal, stay put
      return { ...current, row: newRow, col: newCol };
    });
  }

  function tryRotate() {
    setPiece((current) => {
      const rotatedShape = rotate(current.shape);
      const rotated = { ...current, shape: rotatedShape };
      if (hasCollision(board, rotated, current.row, current.col)) return current; // would overlap, cancel
      return rotated;
    });
  }

  return { board, piece, tryMove, tryRotate };
}

Both functions follow the same shape as hasCollision from the last lesson: compute what the piece would look like, check it, and only apply the change if it's legal. Using the updater form of setPiece (setPiece((current) => ...)) rather than reading piece directly matters here, it guarantees you're always checking against the truly current state, even if multiple moves happen in quick succession before a re-render.

On-screen buttons

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

function Controls({ onLeft, onRight, onRotate, onDrop }: {
  onLeft: () => void;
  onRight: () => void;
  onRotate: () => void;
  onDrop: () => void;
}) {
  return (
    <View style={styles.row}>
      <TouchableOpacity style={styles.button} onPress={onLeft}><Text style={styles.label}>◀</Text></TouchableOpacity>
      <TouchableOpacity style={styles.button} onPress={onRotate}><Text style={styles.label}>⟳</Text></TouchableOpacity>
      <TouchableOpacity style={styles.button} onPress={onDrop}><Text style={styles.label}>▼</Text></TouchableOpacity>
      <TouchableOpacity style={styles.button} onPress={onRight}><Text style={styles.label}>▶</Text></TouchableOpacity>
    </View>
  );
}

const styles = StyleSheet.create({
  row: { flexDirection: 'row', justifyContent: 'space-around', padding: 16 },
  button: { backgroundColor: '#1e293b', borderRadius: 12, paddingVertical: 14, paddingHorizontal: 20 },
  label: { color: '#fff', fontSize: 20 },
});

TouchableOpacity is React Native's tappable-button-with-a-fade-feedback component, roughly the mobile equivalent of a styled <button> on the web. Each button just calls one of the handler functions from useTetris, onLeft={() => tryMove(0, -1)}, onRight={() => tryMove(0, 1)}, onDrop={() => tryMove(1, 0)}, onRotate={tryRotate}.

TIP

Simple on-screen buttons like this are the easiest way to get Tetris fully playable on a touchscreen. Swipe gestures (via react-native-gesture-handler) feel more natural for a polished version, that's one of this course's final project stretch goals.

📝 Touch Controls Quiz

Passing score: 70%
  1. 1.Why does tryMove() use the updater form setPiece((current) => ...) instead of reading piece directly?

  2. 2.TouchableOpacity is roughly the mobile equivalent of a styled <button> on the web.

  3. 3.tryRotate() computes a rotated shape, checks it with hasCollision(), and only applies it if there is no ____.