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:
The state variable (
tetrominoPosition
) with the value you stored.The state setter function (
setTetrominoPosition
) which can update the state variable and trigger React to render the component again.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) | |
adding | push , unshift | concat , [...arr] spread syntax (example) |
removing | pop , shift , splice | filter , slice (example) |
replacing | splice , arr[i] = ... assignment | map (example) |
sorting | reverse , sort | copy 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!