The Game Loop: Gravity and Collision
A static board and a rotatable shape aren't a game yet, they need to actually fall, and stop falling when they hit something. This lesson builds both.
Piece state
interface ActivePiece {
shape: Matrix;
shapeName: string;
row: number;
col: number;
}
function spawnPiece(shapeName: string): ActivePiece {
const shape = SHAPES[shapeName];
return {
shape,
shapeName,
row: 0,
col: Math.floor((BOARD_WIDTH - shape[0].length) / 2),
};
}
row/col track the piece's top-left corner on the board, everything about "where the piece is" lives in these two numbers plus its (possibly rotated) shape matrix. Centering the spawn column keeps every piece appearing in the middle of the board regardless of its width.
Collision detection
function hasCollision(board: Matrix, piece: ActivePiece, row: number, col: number): boolean {
for (let r = 0; r < piece.shape.length; r++) {
for (let c = 0; c < piece.shape[r].length; c++) {
if (!piece.shape[r][c]) continue; // empty cell within the piece's bounding box
const boardRow = row + r;
const boardCol = col + c;
const outOfBounds = boardCol < 0 || boardCol >= BOARD_WIDTH || boardRow >= BOARD_HEIGHT;
const collidesWithLocked = boardRow >= 0 && board[boardRow][boardCol] !== 0;
if (outOfBounds || collidesWithLocked) return true;
}
}
return false;
}
This checks a hypothetical position (row, col), not necessarily the piece's current one, that's deliberate: every move (left, right, rotate, down) works the same way, compute where the piece would end up, ask hasCollision whether that's legal, and only commit the move if it isn't. boardRow >= 0 guards against checking above the board (pieces spawn partially off-screen while rotating near the top), where there's no board row to safely index into.
The falling tick
import { useEffect, useState } from 'react';
function useGameLoop(speedMs: number, onTick: () => void) {
useEffect(() => {
const interval = setInterval(onTick, speedMs);
return () => clearInterval(interval);
}, [speedMs, onTick]);
}
setInterval calls onTick (which will move the active piece down by one row) every speedMs milliseconds, this is Tetris's gravity. Returning clearInterval from useEffect is essential, without it, every re-render (or speed change) would start a new interval on top of the old one, stacking up multiple ticks firing simultaneously.
WARNING
onTick needs to be stable across renders (wrapped in useCallback, covered in the next lesson) or this effect re-runs (clearing and restarting the interval) far more often than intended, since [speedMs, onTick] includes it as a dependency.