Learn React by coding the classic tetris game: Part 3

Learn React by coding the classic tetris game: Part 3

Chapter 3: Handling User Input: Moving and Rotating Tetrominoes

In this chapter, we'll add user input handling to enable the player to move and rotate the falling tetromino. We'll implement event listeners and keyboard controls to make the game interactive.

Step 1: Adding State for Tetromino Position

Open the src/components/GameBoard.jsx file and modify the GameBoard component to accept a tetromino prop, representing the current falling tetromino position. Additionally, add a tetrominoPosition state to the component to keep track of the tetromino's current position.

// src/components/GameBoard.js
import React, { useState } from 'react';
import './GameBoard.css';

const GameBoard = ({ board, tetromino }) => {
  const [tetrominoPosition, setTetrominoPosition] = useState({ x: 0, y: 0 });

  // Rest of the component code...
};

Meet your first hook

To add a state variable, we useuseState from React at the top of the file:

In React, useState, as well as any other function starting with ”use”, is called a Hook.

Hooks are special functions that are only available while React is rendering. They let you “hook into” different React features.

When you call useState, you are telling React that you want this component to remember something:

The convention is to name this pair like const [state, setState]. You could name it anything you like, but conventions make things easier to understand across projects.

The only argument to useState is the initial value of your state variable. In this example, the tetromino’s initial value is set to {x: 0, y:0} with useState({x: 0, y: 0}).

Every time your component renders, useState gives you an array containing two values:

  1. The state variable (tetrominoPosition) with the value you stored.

  2. The state setter function (setTetrominoPosition) which can update the state variable and trigger React to render the component again.

  3. You can have as many state variables of as many types as you like in one component. It is a good idea to have multiple state variables if their state is unrelated. But if you find that you often change two state variables together, it might be easier to combine them into one.

Don't mutate state while using react hook. pass a new state or a function that calculates the new State to the setState function. Be very cautious when updating objects or arrays in the state. Don't use mutating methods, or modify them directly. Create a new array/object with an updated state and pass it to the setState function. Treat state variables as read-only.

Here is a reference table of common array operations. When dealing with arrays inside React state, you will need to avoid the methods in the left column, and instead prefer the methods in the right column:

avoid (mutates the array)prefer (returns a new array)
addingpush, unshiftconcat, [...arr] spread syntax (example)
removingpop, shift, splicefilter, slice (example)
replacingsplice, arr[i] = ... assignmentmap (example)
sortingreverse, sortcopy the array first (example)

The state is isolated and private

The state is local to a component instance on the screen. In other words, if you render the same component twice, each copy will have a completely isolated state! Changing one of them will not affect the other.

Step 2: Handling User Input

To handle user input, we'll create a new function handleKeyPress inside the GameBoard component. This function will listen for keyboard events and update the tetromino position based on the player's input.

// src/components/GameBoard.jsx
import React, { useState, useEffect } from 'react';
import './GameBoard.css';

const GameBoard = ({ board, tetromino }) => {
  const [tetrominoPosition, setTetrominoPosition] = useState({ x: 0, y: 0 });

  // Handle user input to move and rotate the tetromino
  const handleKeyPress = (event) => {
    // Add logic to update the tetromino position based on the player's input
  };

  useEffect(() => {
    // Add event listeners for keyboard events
    window.addEventListener('keydown', handleKeyPress);

    // Clean up event listeners on component unmount
    return () => {
      window.removeEventListener('keydown', handleKeyPress);
    };
  }, []);

  // Rest of the component code...
};

Synchronizing with effects in react

useEffect is a React Hook that allows us to perform side effects in functional components. Side effects can include things like data fetching, subscriptions, or manually interacting with the DOM. In our Tetris game, we use useEffect to set up event listeners for keyboard input, enabling the player to move and rotate the falling tetromino. By providing an empty dependency array as the second argument to useEffect, we ensure that the event listeners are added only once when the component is mounted and removed when the component is unmounted, preventing memory leaks and unnecessary event listener duplication.

Effects let you specify side effects that are caused by rendering itself, rather than by a particular event.

Step 3: Updating Tetromino Position

Now, inside the handleKeyPress function, we'll update the tetrominoPosition state based on the player's input. For example, we can use the arrow keys to move the tetromino left, right, and down, and use the "Up" arrow key to rotate it.

// src/components/GameBoard.js
import React, { useState, useEffect } from 'react';
import './GameBoard.css';

const GameBoard = ({ board, tetromino }) => {
  const [tetrominoPosition, setTetrominoPosition] = useState({ x: 0, y: 0 });


// Implement a function to check if the tetromino move is valid...
  const isValidMove = 
    (x, y, shape) => {
      for (let row = 0; row < shape.length; row++) {
        for (let col = 0; col < shape[0].length; col++) {
          if (shape[row][col] !== '') {
            // Check if the move is within the board boundaries and if the cell is vacant
            if (
              x + col < 0 ||
              x + col >= board[row].length ||
              y + row >= board.length ||
              board[y + row][x + col] !== ''
            ) {
              return false; // Collision detected
            }
          }
        }
      }
      return true; // Valid move, no collision
    };

 // Implement tetromino rotation logic...
  const rotateTetromino = () => {
    const rotatedTetromino = tetromino.map((row) => [...row]);

    // Rotate the tetromino shape using the transpose and reverse logic
    for (let y = 0; y < rotatedTetromino.length; ++y) {
      for (let x = 0; x < y; ++x) {
        [rotatedTetromino[y][x], rotatedTetromino[x][y]] = [
          rotatedTetromino[x][y],
          rotatedTetromino[y][x],
        ];
      }
    }
    rotatedTetromino.forEach((row) => row.reverse());

    // Check if the rotation is a valid move
    const isValidRotation = isValidMove(
      tetrominoPosition.x,
      tetrominoPosition.y,
      rotatedTetromino
    );
    if (isValidRotation) {
      setTetromino(rotatedTetromino);
    }
  };


  // Handle user input to move and rotate the tetromino
  const handleKeyPress = (event) => {
    const { key } = event;
    const newPosition = { ...tetrominoPosition };
    let newX = newPosition.x;
    let newY = newPosition.y;

    switch (key) {
      case 'ArrowLeft':
        newX = tetrominoPosition.x - 1;
        break;
      case 'ArrowRight':
        newX = tetrominoPosition.x + 1;
        break;
      case 'ArrowDown':
        newY = tetrominoPosition.y + 1;
        break;
      case 'ArrowUp':
        // Implement tetromino rotation logic
        rotateTetromino();
        break;
      default:
        break;
    }

    // Update the tetromino position if it's a valid move
    if (isValidMove(newX, newY, tetromino)) {
      setTetrominoPosition({ x: newX, y: newY });
    }
  };

  useEffect(() => {
    // Add event listeners for keyboard events
    window.addEventListener('keydown', handleKeyPress);

    // Clean up event listeners on component unmount
    return () => {
      window.removeEventListener('keydown', handleKeyPress);
    };
  }, []);

  return (
    <div className="game-board">
      {board.map((row, rowIndex) => (
        <div key={rowIndex} className="board-row">
          {row.map((cell, cellIndex) => {
            // Check if the current cell matches the tetromino position...

           const isTetrominoCell =
              rowIndex >= tetrominoPosition.y &&
              rowIndex < tetrominoPosition.y + tetromino.length &&
              cellIndex >= tetrominoPosition.x &&
              cellIndex < tetrominoPosition.x + tetromino[0].length &&
              tetromino[rowIndex - tetrominoPosition.y][cellIndex - tetrominoPosition.x] !== '';

            // Get the tetromino shape character (e.g., I, J, L, O, S, T, Z)
            const tetrominoShape = isTetrominoCell
              ? tetromino[rowIndex - tetrominoPosition.y][cellIndex - tetrominoPosition.x]
              : '';

            // Apply appropriate class for tetromino cells
            const cellClass = isTetrominoCell ? `board-cell ${tetrominoShape}` : 'board-cell';


            return <div key={cellIndex} className={cellClass} />;
          })}
        </div>
      ))}
    </div>
  );
};

export default GameBoard;

Step 4: Rendering the Tetromino on the Grid

Next, we'll render the tetromino on the grid. Inside the GameBoard component, update the JSX to include the tetromino cells in the grid based on the tetrominoPosition.

// src/components/GameBoard.jsx
import React, { useState, useEffect } from 'react';
import './GameBoard.css';

const GameBoard = ({ board, tetromino }) => {
  const [tetrominoPosition, setTetrominoPosition] = useState({ x: 0, y: 0 });

  // Handle user input to move and rotate the tetromino...

  // Implement a function to check if the tetromino move is valid...

  useEffect(() => {
    // Add event listeners for keyboard events...

    // Clean up event listeners on component unmount...
  }, []);

  return (
    <div className="game-board">
      {board.map((row, rowIndex) => (
        <div key={rowIndex} className="board-row">
          {row.map((cell, cellIndex) => {
            // Check if the current cell matches the tetromino position
            const isTetrominoCell =
              rowIndex >= tetrominoPosition.y &&
              rowIndex < tetrominoPosition.y + tetromino.length &&
              cellIndex >= tetrominoPosition.x &&
              cellIndex < tetrominoPosition.x + tetromino[0].length &&
              tetromino[rowIndex - tetrominoPosition.y][cellIndex - tetrominoPosition.x] !== '';

            // Get the tetromino shape character (e.g., I, J, L, O, S, T, Z)
            const tetrominoShape = isTetrominoCell
              ? tetromino[rowIndex - tetrominoPosition.y][cellIndex - tetrominoPosition.x]
              : '';

            // Apply appropriate class for tetromino cells
            const cellClass = isTetrominoCell ? `board-cell ${tetrominoShape}` : 'board-cell';

            return <div key={cellIndex} className={cellClass} />;
          })}
        </div>
      ))}
    </div>
  );
};

export default GameBoard;

Step 5: Styling the Tetromino Cells

To make the tetromino cells visually distinct from the empty cells, update the GameBoard.css file with appropriate styling:

/* src/components/GameBoard.css */
.game-board {
  display: flex;
  flex-direction: column;
  border: 2px solid #333;
}

.board-row {
  display: flex;
}

.board-cell {
  width: 30px;
  height: 30px;
  border: 1px solid #ccc;
  background-color: #f0f0f0;
}

.board-cell.I {
  background-color: #00f;
}

.board-cell.J {
  background-color: #0000cc;
}

.board-cell.L {
  background-color: #ff6600;
}

.board-cell.O {
  background-color: #ffff00;
}

.board-cell.S {
  background-color: #00cc00;
}

.board-cell.T {
  background-color: #cc00cc;
}

.board-cell.Z {
  background-color: #cc0000;
}

Step 6: Initializing Tetromino Data

In the App.jsx file, initialize the tetromino data using a two-dimensional array for each tetromino shape. For simplicity, we'll represent each shape with a unique letter:

// src/App.jsx
import React from 'react';
import './App.css';
import GameTitle from './components/GameTitle';
import GameBoard from './components/GameBoard';

const App = () => {
 const tetromino = [
    // Each element in this array represents a tetromino shape (an array of strings)
    [
      // A tetromino shape is represented as a 2D array of strings (e.g., 'I' for a square, '' for an empty cell)
      ['', 'I', '', ''],
      ['', 'I', '', ''],
      ['', 'I', '', ''],
      ['', 'I', '', ''],
    ],
    [
      ['', 'J', ''],
      ['', 'J', ''],
      ['J', 'J', ''],
    ],
    [
      ['L', '', ''],
      ['L', '', ''],
      ['L', 'L', ''],
    ],
    [
      ['O', 'O'],
      ['O', 'O'],
    ],
    [
      ['', 'S', 'S'],
      ['S', 'S', ''],
      ['', '', ''],
    ],
    [
      ['', 'T', ''],
      ['T', 'T', 'T'],
      ['', '', ''],
    ],
    [
      ['Z', 'Z', ''],
      ['', 'Z', 'Z'],
      ['', '', ''],
    ],
  ];

  const board = Array(20).fill(Array(10).fill('')); // Initialize an empty 20x10 board

  return (
    <div className="App">
      <header className="App-header">
        <GameTitle />
        <GameBoard board={board} tetromino={tetrominos.I} /> {/* Use the GameBoard component here */}
        <p>
          Edit <code>src/App.jsx</code> and save to reload.
        </p>
        <a
          className="App-link"
          href="https://reactjs.org"
          target="_blank"
          rel="noopener noreferrer"
        >
          Learn React
        </a>
      </header>
    </div>
  );
};

export default App;

Step 7: Run the Development Server

Now that you've implemented the tetromino rendering and user input handling, run the development server to see the Tetris grid with the falling tetromino. In your terminal, make sure you are in the project's root directory (tetris-game) and run the following command:

npm run dev

you should be able to see a tetromino at the top of your board. However if you try to move it, it is only able to move one point in right and left and then it is struck at the top. Well, we did update the tetrominosPosition using setTetrominosPosition. If you'll log the value of tetrominosPosition at the first render, the value of object that was passed to setTetrominosPosition on keypress event and the value of tetrominosPosition at the second render and the value that is being received by handleKeyPress event listener, you'll find that state was updated, but even then on next render the handleKeyPress gets the initial value of tetrominosPosition even though we updated state. Why's that ?

The problem lies in the event listener being added inside the useEffect hook. Since the event listener is created only once, it captures the initial state of tetrominoPosition and keeps using that state, even if the state changes later on. It's for this reason, that you should almost always never add an event listener in vanilla javascript fashion in react.

To fix this, we need to update the handleKeyPress function to utilize the latest state of tetrominoPosition correctly. We can achieve this by using the useCallback hook with a dependency on tetrominoPosition to create a new event listener whenever the state changes.

Here's the updated useEffect and handleKeyPress functions:

useEffect(() => {
  // Set up the event listener with the latest handleKeyPress function
  const handleKeyPressWithState = (event) => handleKeyPress(event, tetrominoPosition);
  window.addEventListener('keydown', handleKeyPressWithState);

  // Clear the event listener on component unmount
  return () => {
    window.removeEventListener('keydown', handleKeyPressWithState);
  };
}, [handleKeyPress, tetrominoPosition]);

Now, let's update the handleKeyPress function to accept the current tetrominoPosition as a parameter:

const handleKeyPress = useCallback((event, currentTetrominoPosition) => {
  if (isGameOver) return;
  const { key } = event;

  // Create a new object for the updated position
  let newPosition = { ...currentTetrominoPosition };

  // Rest of the function remains unchanged
  // ...
}, [isGameOver, tetromino, rotationIndex]);

By using useCallback and adding currentTetrominoPosition as a parameter, we ensure that the handleKeyPress function will always use the latest tetrominoPosition value when it is called.

useCallback is a React Hook that lets you cache a function definition between re-renders.

With these changes, the event listener should correctly capture the updated tetrominoPosition, allowing the tetromino to move as expected. You should now be able to move your tetromino right, left, down and rotate it.

Conclusion

In this chapter, we added user input handling to enable the player to move and rotate the falling tetromino using keyboard controls. We created a GameBoard component that updates the tetromino position in response to user input. Additionally, we styled the tetromino cells to visually distinguish them from the empty cells on the grid. In the next chapter, we'll implement the game logic to manage the tetromino's falling and clearing completed lines. Stay tuned as our Tetris game with React series continues to evolve!