Chapter 4: Managing Game State: Implementing Game Logic
In this chapter, we'll manage the game state using React's useState hook. We'll implement game logic to handle tetromino movement, collision detection, and clearing completed rows.
Step 1: Updating State for Game Over
We'll start by adding state variables to manage the game state, including the current tetromino, its position, the game board, and whether the game is over.
But before that, we should modularize our codebase a little, otherwise, it'll get to complex to deal with.
Create a file named utility.js in src/component directory. and add the following code.
// src.components/utility.js
const getRandomTetromino = () => {
const tetrominoShapes = [
// 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'],
['', '', ''],
],
];
// Select a random index from the tetrominoShapes array
const randomIndex = Math.floor(Math.random() * tetrominoShapes.length);
// Return the selected tetromino shape
return tetrominoShapes[randomIndex];
};
const ROWS = 20;
const COLS = 10;
// Function to get an empty board
const getEmptyBoard = () => {
return Array.from({ length: ROWS }, () => Array(COLS).fill(''));
};
export { getEmptyBoard, getRandomTetromino };
It exports two utility functions to get the instance of a new Tetromino and a new board. We'll be needing it a lot later. Likewise, we'll update our App.jsx and GameBoard.jsx.
// src/App.jsx
// src/App.jsx
import React from 'react';
import './App.css';
import GameTitle from './components/GameTitle';
import GameBoard from './components/GameBoard';
const App = () => {
return (
<div className="App">
<header className="App-header">
<GameTitle />
<GameBoard /> {/* 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;
// src/components/GameBoard.jsx
import React, { useState, useEffect } from 'react';
import { getRandomTetromino, getEmptyBoard } from './utility';
import './GameBoard.css';
const GameBoard = () => {
const [board, setBoard] = useState(() => getEmptyBoard());
const [tetrominoPosition, setTetrominoPosition] = useState({ x: 5, y: 0 });
const [isGameOver, setIsGameOver] = useState(false);
// Implement a function to check if the tetromino move is valid
// ... (previous code)
// Handle user input to move and rotate the tetromino
// ... (previous code)
useEffect(() => {
// Add event listeners for keyboard events
// ... (previous code)
// Clean up event listeners on component unmount
// ... (previous code)
}, [handleKeyPress, tetrominoPosition]);
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
// ... (previous code)
return <div key={cellIndex} className={cellClass} />;
})}
</div>
))}
{isGameOver && <div className="game-over">Game Over</div>}
</div>
);
};
export default GameBoard;
we'll be using the getRandomTetromino later in our code.
Step 2: Implementing Tetromino Movement and Collision Detection
We've already implemented the handling of keypress inputs. Our tetromino moves left, right, and rotates and is also able to detect collision. Now we need to make our tetromino move at a regular interval of time. For that we'll be using another hook known as useRef
useRef
is a React Hook that lets you reference a value that’s not needed for rendering.
Parameters
initialValue
: The value you want the ref object’scurrent
property to be initially. It can be a value of any type. This argument is ignored after the initial render.
Returns
useRef
returns an object with a single property:
current
: Initially, it’s set to theinitialValue
you have passed. You can later set it to something else. If you pass the ref object to React as aref
attribute to a JSX node, React will set itscurrent
property.
On the next renders, useRef
will return the same object.
we'll use useRef to create our gameLoop in the useEffect.
update the Gameboard.jsx
// 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 });
const [rotationIndex, setRotationIndex] = useState(0);
const [isGameOver, setIsGameOver] = useState(false);
// Handle user input to move and rotate the tetromino
// ... (previous code)
// Implement tetromino rotation logic
// ... (previous code)
// Implement a function to check if the tetromino move is valid
// ... (previous code)
useEffect(() => {
// Add event listeners for keyboard events
const handleKeyPressWithState = (event) =>
handleKeyPress(event, tetrominoPosition);
window.addEventListener('keydown', handleKeyPressWithState);
// Set a game loop interval to move the tetromino down at regular intervals
gameLoopIntervalRef.current = setInterval(() => {
const newPosition = { ...tetrominoPosition };
newPosition.y = newPosition.y + 1;
if (isValidMove(newPosition.x, newPosition.y, tetromino)) {
setTetrominoPosition(newPosition);
} else {
// If the tetromino cannot move down, lock it in its current position and generate a new one
//we'll also have to update our gaming board with occupied cells
}
}, 700); // Adjust the interval for the desired falling speed
// Clear the game loop interval and event listener on component unmount
return () => {
clearInterval(gameLoopIntervalRef.current);
window.removeEventListener('keydown', handleKeyPressWithState);
};
}, [
handleKeyPress,
tetrominoPosition,
]);
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
// ... (previous code)
return <div key={cellIndex} className={cellClass} />;
})}
</div>
))}
{isGameOver && <div className="game-over">Game Over</div>}
</div>
);
};
export default GameBoard;
This will make our tetromino move down, at an interval of 700ms.
Step 4: Update the board and generate a new tetromino
When our tetromino eventually reaches the bottom of our board, we want to do the following things:
Freeze the tetromino and update the state of the board, so it can remember the tetromino has frozen there.
Generate a new tetromino to continue the game.
We'll add these two functions to the code and modify our useEffect to include them.
// src/components/Gameboard.jsx
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { getRandomTetromino, getEmptyBoard } from './utility';
import './GameBoard.css';
const GameBoard = () => {
const [board, setBoard] = useState(() => getEmptyBoard());
const [tetromino, setTetromino] = useState(() => getRandomTetromino());
const [tetrominoPosition, setTetrominoPosition] = useState({ x: 0, y: 0 });
const [isGameOver, setIsGameOver] = useState(false);
const gameLoopIntervalRef = useRef(null);
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
};
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);
}
};
// Function to handle tetromino rotation
const handleKeyPress = useCallback(
(event, currentTetrominoPosition) => {
if (isGameOver) return;
const { key } = event;
console.log('previous position', currentTetrominoPosition);
const newPosition = { ...currentTetrominoPosition };
console.log('newPosition', newPosition);
console.log(newPosition === currentTetrominoPosition);
switch (key) {
case 'ArrowLeft':
console.log('arrow-left');
newPosition.x = newPosition.x - 1;
break;
case 'ArrowRight':
console.log('arrow-right');
newPosition.x = newPosition.x + 1;
break;
case 'ArrowDown':
console.log('arrow-down');
newPosition.y = newPosition.y + 1;
break;
case 'ArrowUp':
console.log('arrow-up');
rotateTetromino();
break;
default:
break;
}
console.log('tetromino position', currentTetrominoPosition);
console.log('updated position', newPosition);
// Update the tetromino position if it's a valid move
if (isValidMove(newPosition.x, newPosition.y, tetromino)) {
console.log("it's valid move");
setTetrominoPosition({ x: newPosition.x, y: newPosition.y });
}
},
[isGameOver, isValidMove, rotateTetromino, tetromino]
);
// Function to freeze the tetromino in its current position
const freezeTetromino = () => {
setBoard((prevBoard) => {
const newBoard = prevBoard.map((row) => [...row]);
tetromino.forEach((row, y) => {
row.forEach((value, x) => {
if (value !== '') {
// Calculate the actual coordinates on the game board for each cell of the tetromino
const boardX = tetrominoPosition.x + x;
const boardY = tetrominoPosition.y + y;
// Update the corresponding cell on the new board with the value of the tetromino
newBoard[boardY][boardX] = value;
}
});
});
console.log('updated board', newBoard);
// Check if the game is over (i.e., the new tetromino has collided with existing cells at the top)
setIsGameOver(newBoard[0].some((cell) => cell !== ''));
return newBoard;
});
};
// Function to generate a new random tetromino
const generateNewTetromino = () => {
const randomTetromino = getRandomTetromino(); //get a new tetromino
const initialX =
Math.floor(board[0].length / 2) -
Math.floor(randomTetromino[0].length / 2);
setTetromino(randomTetromino);
setTetrominoPosition({ x: initialX, y: 0 });
// Update the board with the new tetromino
};
useEffect(() => {
// Add event listeners for keyboard events
const handleKeyPressWithState = (event) =>
handleKeyPress(event, tetrominoPosition);
window.addEventListener('keydown', handleKeyPressWithState);
// Set a game loop interval to move the tetromino down at regular intervals
gameLoopIntervalRef.current = setInterval(() => {
const newPosition = { ...tetrominoPosition };
newPosition.y = newPosition.y + 1;
if (isValidMove(newPosition.x, newPosition.y, tetromino)) {
setTetrominoPosition(newPosition);
} else {
// If the tetromino cannot move down, lock it in its current position and generate a new one
freezeTetromino();
generateNewTetromino();
}
}, 700); // Adjust the interval for the desired falling speed
// Clear the game loop interval and event listener on component unmount
return () => {
clearInterval(gameLoopIntervalRef.current);
window.removeEventListener('keydown', handleKeyPressWithState);
};
}, [
board,
freezeTetromino,
generateNewTetromino,
handleKeyPress,
isValidMove,
tetromino,
tetrominoPosition,
]);
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 + ' ' + cell} />;
})}
{isGameOver && <div className="game-over">Game Over</div>}
</div>
))}
</div>
);
};
export default GameBoard;
Everything seems right, the code should work.
But it doesn't, something's missing. Since we're adding an event listner via useEffect, we had to use useCallback so that the handleKeyPress gets the updated state everytime, since it's the function we're triggering in our effect.
we need to do the same thing for all the dependencies in useEffect, wrap them in useCallback and provide the dependency array
// src/components/Gameboard.jsx
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { getRandomTetromino, getEmptyBoard } from './utility';
import './GameBoard.css';
const GameBoard = () => {
const [board, setBoard] = useState(() => getEmptyBoard());
const [tetromino, setTetromino] = useState(() => getRandomTetromino());
const [tetrominoPosition, setTetrominoPosition] = useState({ x: 0, y: 0 });
const [isGameOver, setIsGameOver] = useState(false);
const gameLoopIntervalRef = useRef(null);
const isValidMove = useCallback(
(x, y, shape) => {
//previous code
},
[board]
);
const rotateTetromino = useCallback(() => {
const rotatedTetromino = tetromino.map((row) => [...row]);
// Rotate the tetromino shape using the transpose and reverse logic
//previous code
}, [isValidMove, tetromino, tetrominoPosition.x, tetrominoPosition.y]);
// Function to handle tetromino rotation
const handleKeyPress = useCallback(
(event, currentTetrominoPosition) => {
if (isGameOver) return;
const { key } = event;
//previous code
},
[isGameOver, isValidMove, rotateTetromino, tetromino]
);
// Function to freeze the tetromino in its current position
const freezeTetromino = useCallback(() => {
//previous code
}, [tetromino, tetrominoPosition]);
// Function to generate a new random tetromino
const generateNewTetromino = useCallback(() => {
const randomTetromino = getRandomTetromino();
// previous code
}, [board]);
useEffect(() => {
// Add event listeners for keyboard events
//previous code
};
}, [
board,
freezeTetromino,
generateNewTetromino,
handleKeyPress,
isValidMove,
tetromino,
tetrominoPosition,
]);
return (
<div className="game-board">
{board.map((row, rowIndex) => (
<div key={rowIndex} className="board-row">
{ /*
previous code here
*/}
</div>
))}
</div>
);
};
export default GameBoard;
Step 5: Clear the completed rows.
Now, you'll have something that looks like an actual game. The tetrominoes just keep on falling. But, this way it'll be game over before you blink. We need to clear the rwos that have completely filled. Let's add the function for it.
We'll also have to call the function at the appropriate place and also add it to the dependency array. And again, we'll have to wrap it in useCallback.
// src/components/Gameboard.jsx
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { getRandomTetromino, getEmptyBoard } from './utility';
import './GameBoard.css';
const GameBoard = () => {
const [board, setBoard] = useState(() => getEmptyBoard());
const [tetromino, setTetromino] = useState(() => getRandomTetromino());
const [tetrominoPosition, setTetrominoPosition] = useState({ x: 0, y: 0 });
const [isGameOver, setIsGameOver] = useState(false);
const gameLoopIntervalRef = useRef(null);
const isValidMove = useCallback(
(x, y, shape) => {
//previous code
},
[board]
);
const rotateTetromino = useCallback(() => {
const rotatedTetromino = tetromino.map((row) => [...row]);
// Rotate the tetromino shape using the transpose and reverse logic
//previous code
}, [isValidMove, tetromino, tetrominoPosition.x, tetrominoPosition.y]);
// Function to handle tetromino rotation
const handleKeyPress = useCallback(
(event, currentTetrominoPosition) => {
if (isGameOver) return;
const { key } = event;
//previous code
},
[isGameOver, isValidMove, rotateTetromino, tetromino]
);
// Function to clear completed rows
const clearCompletedRows = useCallback(
(currentBoard) => {
const completedRows = currentBoard.reduce((acc, row, rowIndex) => {
if (row.every((cell) => cell !== '')) {
acc.push(rowIndex);
}
return acc;
}, []);
if (completedRows.length > 0) {
// Clear completed rows
const updatedBoard = currentBoard.filter(
(_, rowIndex) => !completedRows.includes(rowIndex)
);
// Add empty rows at the top
const emptyRows = Array.from({ length: completedRows.length }, () =>
Array(board[0].length).fill('')
);
// Combine the empty rows and the updated board
return [...emptyRows, ...updatedBoard];
}
return currentBoard;
},
[board]
);
// Function to freeze the tetromino in its current position
const freezeTetromino = useCallback(() => {
setBoard((prevBoard) => {
const newBoard = prevBoard.map((row) => [...row]);
tetromino.forEach((row, y) => {
row.forEach((value, x) => {
if (value !== '') {
// Calculate the actual coordinates on the game board for each cell of the tetromino
const boardX = tetrominoPosition.x + x;
const boardY = tetrominoPosition.y + y;
// Update the corresponding cell on the new board with the value of the tetromino
newBoard[boardY][boardX] = value;
}
});
});
console.log('prevBoard', prevBoard);
console.log('newboard', newBoard);
// Clear completed lines and update the game board
const updatedBoard = clearCompletedRows(newBoard);
console.log('updated board', updatedBoard);
// Check if the game is over (i.e., the new tetromino has collided with existing cells at the top)
setIsGameOver(updatedBoard[0].some((cell) => cell !== ''));
return updatedBoard;
});
}, [tetromino, clearCompletedRows, tetrominoPosition]);
// Function to generate a new random tetromino
const generateNewTetromino = useCallback(() => {
const randomTetromino = getRandomTetromino();
// previous code
}, [board]);
useEffect(() => {
// Add event listeners for keyboard events
//previous code
};
}, [
board,
freezeTetromino,
generateNewTetromino,
handleKeyPress,
isValidMove,
tetromino,
tetrominoPosition,
]);
return (
<div className="game-board">
{board.map((row, rowIndex) => (
<div key={rowIndex} className="board-row">
{ /*
previous code here
*/}
</div>
))}
</div>
);
};
export default GameBoard;
Step 6: Display the frozen cells on the board.
Our game works, tetrominoes are falling and if you log the board you'll see it's value is also getting updated. But new tetrominoes are able to detect the frozen tetrominoes. They just are not visible to our eyes. Let's style our board so that it displays the frozen tetrominoes with appropriate colors. it's really simple.
We just have to make a little change in the component rendered by GameBoard.
// src/components/GameBoard.jsx
const GameBoard = () => {
//all other code
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';
{/* we just add the value of { cell } to the classname for each cell */}
return <div key={cellIndex} className={cellClass + ' ' + cell} />;
})}
{isGameOver && <div className="game-over">Game Over</div>}
</div>
))}
</div>
);
};
}
Conclusion
In this chapter, we successfully implemented game logic to handle tetromino movement, collision detection, and row clearing in our Tetris game built with React. We updated the useState
hook to manage the game state, and the useEffect
hook to trigger game events, such as generating new tetrominoes and moving them down at regular intervals. We wrapped our handlers in useCallback
so that, our functions get the most recent state.
We implemented functions to lock the tetromino in its current position, generate new random tetrominoes, and clear completed rows from the game board. These functions allowed us to create a fluid gameplay experience and maintain a clean and organized codebase.
With the game logic in place, players can now interact with the game using arrow keys to move and rotate the falling tetromino. When a row is completed, it will be cleared, and players can continue to stack tetrominoes to achieve higher scores.
In the next chapter, we will focus on enhancing the user interface with visuals, adding sound effects, and implementing scoring mechanics. Stay tuned for the next exciting chapter!