Locking Pieces and Clearing Lines
When a falling piece can't move down any further, it needs to permanently lock into the board, and any completely filled rows need to disappear. This is the heart of Tetris's core loop.
Locking a piece into the board
function lockPiece(board: Matrix, piece: ActivePiece): Matrix {
const newBoard = board.map((row) => [...row]); // copy, never mutate the board in place
const colorId = SHAPE_COLORS[piece.shapeName];
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;
const boardRow = piece.row + r;
const boardCol = piece.col + c;
if (boardRow >= 0) newBoard[boardRow][boardCol] = colorId;
}
}
return newBoard;
}
board.map((row) => [...row]) makes a deep-enough copy, a new outer array and new inner row arrays, so writing into newBoard never mutates the board that was passed in. This matters for the same reason createEmptyBoard's Array.from mattered: React needs a genuinely new array reference to know something changed and re-render.
Finding and clearing full rows
function clearFullRows(board: Matrix): { board: Matrix; linesCleared: number } {
const remainingRows = board.filter((row) => row.some((cell) => cell === 0));
const linesCleared = BOARD_HEIGHT - remainingRows.length;
const emptyRows = Array.from({ length: linesCleared }, () => Array(BOARD_WIDTH).fill(0));
return { board: [...emptyRows, ...remainingRows], linesCleared };
}
row.some((cell) => cell === 0) is true for any row with at least one empty cell, so board.filter(...) keeps only the rows that are not completely full, exactly the rows that survive a line clear. Prepending fresh empty rows ([...emptyRows, ...remainingRows]) at the top is what makes everything above a cleared line visually fall down, the surviving rows just end up lower in the new array.
Wiring it into the tick
function handleTick(board: Matrix, piece: ActivePiece) {
if (!hasCollision(board, piece, piece.row + 1, piece.col)) {
return { board, piece: { ...piece, row: piece.row + 1 }, linesCleared: 0 };
}
// can't fall further: lock it, clear lines, spawn the next piece
const locked = lockPiece(board, piece);
const { board: clearedBoard, linesCleared } = clearFullRows(locked);
const nextPiece = spawnPiece(randomShapeName());
return { board: clearedBoard, piece: nextPiece, linesCleared };
}
Every tick asks one question: can the piece move down one more row? If yes, just move it, business as usual. If no, that's the signal the piece has landed, lock it, clear whatever rows are now full, and spawn a fresh piece to keep the game going.
NOTE
randomShapeName() here just needs to return one of 'I' | 'O' | 'T' | 'S' | 'Z' | 'J' | 'L', a simple Math.random()-based picker works, but produces streaky, unfair randomness (you could see the same piece five times in a row), the "Next Piece and the 7-Bag" lesson replaces it with the fairer system real Tetris games use.