DEAnimationReactTypescriptHTML5 CanvasGame Loop

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.

January 2026
20 min read

1Types & Constants

// Update Step 1 text to mention Canvas

Before 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
// 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 ticks

Click 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.

tetris.tsx
// 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:

7 × 5 = 35 cells

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!

tetris.tsx
// 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.

tetris.tsx
// 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.

tetris.tsx
// 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.

tetris.tsx
// 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

Ready to build more?

Check out the courses to learn how to build advanced animations, interactive components, and production-grade full-stack apps.