Building a High-Performance Tetris Background
Follow along step-by-step to build a self-playing Tetris background effect. We'll migrate from standard DOM nodes to HTML5 Canvas to gain massive performance wins while keeping the game logic clean and reactive.
1Types & Constants
// Update Step 1 text to mention CanvasBefore we write game logic, we define our data. The foundation is the tetromino. We use a 2D array where 1 is filled and 0 is empty. Crucially, instead of React state for every block (which kills performance at scale), we'll define aTHEME_COLORS array. In the new Canvas implementation, we use actual `rgba` values or resolve CSS variables so the canvas context can draw them directly.
// tetris.tsx - Types & Constants
type TetrominoShape = number[][]
interface Cell {
filled: boolean
color?: string | number // Hex string or index
}
interface ActivePiece {
shape: TetrominoShape
x: number
y: number
color: string
}
// The 7 classic Tetris shapes as 2D arrays
// 1 = filled block, 0 = empty space
const SHAPES: Record<string, TetrominoShape> = {
I: [[1, 1, 1, 1]],
L: [[1, 0],[1, 0],[1, 1]],
J: [[0, 1],[0, 1],[1, 1]],
O: [[1, 1],[1, 1]],
Z: [[1, 1, 0],[0, 1, 1]],
S: [[0, 1, 1],[1, 1, 0]],
T: [[1, 1, 1],[0, 1, 0]],
}
// We use CSS variables for theming, or fall back to RGBA.
// Using color-mix mostly preserves transparency while allowing variable usage.
const THEME_COLORS = [
{ fill: "rgba(6, 182, 212, 0.85)", stroke: "rgba(103, 232, 249, 1.0)" }, // Cyan
{ fill: "rgba(139, 92, 246, 0.85)", stroke: "rgba(167, 139, 250, 1.0)" }, // Violet
{ fill: "rgba(16, 185, 129, 0.85)", stroke: "rgba(52, 211, 153, 1.0)" }, // Emerald
{ fill: "rgba(244, 63, 94, 0.85)", stroke: "rgba(251, 113, 133, 1.0)" }, // Rose
{ fill: "rgba(245, 158, 11, 0.85)", stroke: "rgba(251, 191, 36, 1.0)" }, // Amber
]
const GAP = 4 // pixels between blocks
const TICK_RATE = 60 // milliseconds between game ticksClick any shape:
T-Piece
[[1, 1, 1], [0, 1, 0]]
2Canvas Rendering
Previously, we used CSS Grid and motion.div for every block. While declarative, rendering 500+ DOM nodes kills FPS.
Switching to Canvas API allows us to render the entire grid in a single paint operation. We calculate the grid columns based on screen width, then use a single render()function that wipes the canvas and re-draws only the active elements each frame.
// The render function: converting state to pixels
const render = useCallback(() => {
const canvas = canvasRef.current
if (!canvas) return
const ctx = canvas.getContext("2d")
if (!ctx) return
const { width, height, blockSize, offsetY, rows, cols } = dimsRef.current
// Clear canvas
ctx.clearRect(0, 0, width, height)
// 1. Draw Grid Lines
ctx.beginPath()
ctx.strokeStyle = gridLineColorRef.current
ctx.lineWidth = 1
const totalGridWidth = cols * (blockSize + GAP)
const totalGridHeight = rows * (blockSize + GAP)
// Draw vertical & horizontal lines...
for (let c = 0; c <= cols; c++) {
const x = c * (blockSize + GAP)
ctx.moveTo(x, offsetY)
ctx.lineTo(x, offsetY + totalGridHeight)
}
// ... (horizontal lines logic)
ctx.stroke()
// 2. Draw Static Blocks
gridRef.current.forEach((row, rIndex) => {
row.forEach((cell, cIndex) => {
if (!cell.filled) return
// Resolve theme color
const colorObj = themeColors[cell.colorIndex]
const x = cIndex * (blockSize + GAP)
const y = offsetY + rIndex * (blockSize + GAP)
// Custom rounded rect drawing
drawAbstractBlock(ctx, x, y, blockSize, colorObj)
})
})
}, [])Adjust values:
cols = ceil(320 / 46) = 7
rows = ceil(200 / 46) = 5
Live Grid:
3Collision Detection
Every time a piece wants to move (down, left, right, or rotate), we first check if that move is valid. The checkCollision function loops through each filled cell in the shape and checks: (1) would it go outside the grid boundaries? (2) would it overlap with an already-placed block? If either is true, we reject the move. This is the core mechanic that makes Tetris work without it, pieces would fall through the floor!
// Check if a piece would collide at position (x, y)
const checkCollision = (
shape: number[][],
x: number,
y: number,
grid: Cell[][],
rows: number,
cols: number
): boolean => {
for (let r = 0; r < shape.length; r++) {
for (let c = 0; c < shape[r].length; c++) {
if (shape[r][c]) {
const newY = y + r
const newX = x + c
// Check boundaries
if (newX < 0 || newX >= cols || newY >= rows) {
return true // Hit wall or floor
}
// Check existing blocks (ignore if above board)
if (newY >= 0 && grid[newY][newX].filled) {
return true // Hit another block
}
}
}
}
return false // No collision
}✓ Clear to Move
Position: (3, -2)
The function checks:
- • Left/right boundaries (newX < 0 or newX >= cols)
- • Bottom boundary (newY >= rows)
- • Existing blocks (grid[newY][newX].filled)
4AI Best Move
Since this is a background animation, we want pieces to play themselves intelligently. The AI uses a heuristic scoring system that evaluates every possible position and rotation. It considers four factors: lines that would be cleared (good!), holes created under blocks (very bad!), how tall the stack is (bad), and surface bumpiness (bad). By heavily penalizing holes (-5000 points), the AI naturally plays to keep the board clean and flat.
// AI evaluates every possible position and picks the best one
const calculateBestMove = (
baseShape: number[][],
grid: Cell[][],
rows: number,
cols: number
): { bestX: number; bestRotation: number } => {
let bestScore = -Infinity
let bestX = 0
let bestRotation = 0
// Weights for scoring (tune these!)
const WEIGHT_LINES = 1000 // Reward clearing lines
const WEIGHT_HOLES = -5000 // Heavily penalize holes
const WEIGHT_HEIGHT = -50 // Penalize tall stacks
const WEIGHT_BUMPINESS = -100 // Penalize uneven surface
let currentShape = baseShape
// Try all 4 rotations
for (let rot = 0; rot < 4; rot++) {
// Try all X positions
for (let x = 0; x <= cols - currentShape[0].length; x++) {
// Simulate dropping the piece
let y = -currentShape.length
while (!checkCollision(currentShape, x, y + 1, grid, rows, cols)) {
y++
}
// Score this position
const score =
linesCleared * WEIGHT_LINES +
holes * WEIGHT_HOLES +
height * WEIGHT_HEIGHT +
bumpiness * WEIGHT_BUMPINESS
if (score > bestScore) {
bestScore = score
bestX = x
bestRotation = rot
}
}
currentShape = rotateMatrix(currentShape)
}
return { bestX, bestRotation }
}Score Breakdown:
Lines cleared: +1000 (1 × 1000)
Holes penalty: -0 (0 × -5000)
Height penalty: -100 (2 × -50)
Total: 900
5Game Loop
Unlike React state updates which trigger re-renders, HTML5 Canvas requires an explicit loop. We use requestAnimationFrame to sync with the monitor's refresh rate.
Notice how we separate Logic Ticks (60ms) from Rendering Frames (screen Hz). Even if the game logic only updates 15 times a second, we can render at 144Hz for buttery smooth animations (if we added interpolation). This decoupling is the secret to high-performance games.
// Game loop using requestAnimationFrame
useEffect(() => {
let lastTickTime = 0
let animationFrameId: number
const gameLoop = (timestamp: number) => {
// 1. Request next frame immediately
animationFrameId = requestAnimationFrame(gameLoop)
const { rows, cols } = dimsRef.current
if (!rows || !cols) return
// 2. Control Game Speed (Throttle Logic)
if (timestamp - lastTickTime >= TICK_RATE) {
lastTickTime = timestamp
// If clearing lines, skip update logic but keep rendering
if (isClearingRef.current) {
render()
return
}
// 3. Update Game State
const activePiece = activePieceRef.current
if (!activePiece) {
spawnNewPiece()
} else {
moveActivePieceDown()
}
}
// 4. Render every frame
// We decouple physics (TICK_RATE) from rendering (Screen Hz)
render()
}
animationFrameId = requestAnimationFrame(gameLoop)
return () => cancelAnimationFrame(animationFrameId)
}, [render])Animation Preview:
Frame updates: 0
Lower TICK_RATE = faster animation
6Line Clearing
When a row fills up, we don't just delete it instantly. We set a isClearing flag. Our render function detects this and draws the blocks as "flashing white" frames for 300ms. Finally, we splice the grid array and inject new empty rows at the top.
// Check for and clear completed lines
// ... inside lock logic
const rowsToClear: number[] = []
grid.forEach((row, idx) => {
if (row.every(cell => cell.filled)) rowsToClear.push(idx)
})
if (rowsToClear.length > 0) {
// 1. Mark rows for "flash" animation
completedRowsRef.current = rowsToClear
isClearingRef.current = true
activePieceRef.current = null // Stop piece
// 2. Wait, then remove
setTimeout(() => {
const clearedGrid = gridRef.current.filter((_, idx) => !rowsToClear.includes(idx))
const newRows = createEmptyGrid(rowsToClear.length, cols)
gridRef.current = [...newRows, ...clearedGrid]
completedRowsRef.current = []
isClearingRef.current = false
// Logic continues next tick...
}, 300)
} else {
activePieceRef.current = null // Spawn new next tick
}Click cells to toggle • Fill a row completely, then "Check Lines"
Full Source Code
import React, { useCallback, useEffect, useRef, useState } from "react"
// --- Types ---
type TetrominoShape = number[][]
// ... (Blurred Content) ...
// The full source code includes:
// - Advanced Canvas Rendering
// - RequestAnimationFrame Game Loop
// - Collision detection algorithms
// - Performance Optimizations for 60FPS
Want the full source code?
Get access to this and all other interactive components.
Subscribe to Access