← Blog
TypeScriptGame DevelopmentReactState Management

Designing a Type-Safe Game Loop in TypeScript — State Management for Snake and Tetris

10 min read

Go deeper on this topic

Browser Game Development

Game loop design, collision detection, pathfinding, and tooling for browser games

Article 6 of 7 in this series.

View topic page

Use the topic hub as a map, then continue to the next article in the series. New here? Start at the home hub →

Representing Game State with Types

Snake's state is divided into "playing," "game over," and "paused." This is expressed using TypeScript's discriminated union types:

type GameState =
  | { phase: 'playing'; snake: Point[]; food: Point; direction: Direction; score: number }
  | { phase: 'gameover'; finalScore: number }
  | { phase: 'paused'; snapshot: GameState & { phase: 'playing' } }

type Direction = 'up' | 'down' | 'left' | 'right'
type Point = { x: number; y: number }

phase acts as the discriminator, so within a state.phase === 'playing' branch, state.snake can be accessed safely.

useRef vs useState: When to Use Which

In a game loop, data that shouldn't trigger re-renders is managed with useRef:

// useRef: updated at 60fps but shouldn't trigger rendering
const gameStateRef = useRef<GameState>(initialState)
const inputQueueRef = useRef<Direction[]>([])
const lastTickRef = useRef(0)

// useState: reflected in UI
const [score, setScore] = useState(0)
const [phase, setPhase] = useState<'playing' | 'gameover' | 'paused'>('playing')

Calling setState inside a 60fps game loop triggers a re-render every frame. Game logic is updated via useRef, and useState is only synced at score changes or phase transitions.

Input Buffering

Applying key inputs directly means that if "up -> right" is pressed quickly within one frame, the first input gets lost:

function handleKeyDown(e: KeyboardEvent) {
  const dirMap: Record<string, Direction> = {
    ArrowUp: 'up',
    ArrowDown: 'down',
    ArrowLeft: 'left',
    ArrowRight: 'right',
  }

  const dir = dirMap[e.key]
  if (!dir) return

  const queue = inputQueueRef.current
  const last = queue.length > 0 ? queue[queue.length - 1] : currentDirection

  // Ignore reverse direction input (would cause self-collision)
  const opposites: Record<Direction, Direction> = {
    up: 'down', down: 'up', left: 'right', right: 'left',
  }
  if (opposites[dir] === last) return

  queue.push(dir)
}

The front of the queue is consumed on each game tick. This ensures no rapid inputs are dropped.

Fixed Timestep Game Tick

Snake runs at 10fps (100ms intervals), Tetris uses variable timing based on difficulty:

const TICK_INTERVAL = 100 // ms

function gameLoop(timestamp: number) {
  if (gameStateRef.current.phase !== 'playing') return

  if (timestamp - lastTickRef.current >= TICK_INTERVAL) {
    lastTickRef.current = timestamp

    // Get direction from input queue
    const dir = inputQueueRef.current.shift() ?? currentDirection

    // Update game state
    const nextState = tick(gameStateRef.current, dir)
    gameStateRef.current = nextState

    // Only setState for changes that need UI reflection
    if (nextState.phase === 'playing') {
      setScore(nextState.score)
    } else if (nextState.phase === 'gameover') {
      setPhase('gameover')
    }

    // Canvas rendering
    render(nextState)
  }

  rafRef.current = requestAnimationFrame(gameLoop)
}

requestAnimationFrame fires at 60fps, but game ticks occur only once every 100ms. Decoupling rendering and logic frequencies allows smooth animations (fades, etc.) and game speed to be controlled independently.

Tetris Piece Type Definitions

Tetromino shapes are defined as 2D arrays:

const TETROMINOS = {
  I: { shape: [[1, 1, 1, 1]], color: '#00f0f0' },
  O: { shape: [[1, 1], [1, 1]], color: '#f0f000' },
  T: { shape: [[0, 1, 0], [1, 1, 1]], color: '#a000f0' },
  S: { shape: [[0, 1, 1], [1, 1, 0]], color: '#00f000' },
  Z: { shape: [[1, 1, 0], [0, 1, 1]], color: '#f00000' },
  J: { shape: [[1, 0, 0], [1, 1, 1]], color: '#0000f0' },
  L: { shape: [[0, 0, 1], [1, 1, 1]], color: '#f0a000' },
} as const satisfies Record<string, { shape: number[][]; color: string }>

as const satisfies preserves literal types while applying type checking. A TypeScript 4.9+ pattern.

Rotation Logic

90-degree piece rotation is matrix transpose + row reversal:

function rotate(shape: number[][]): number[][] {
  const rows = shape.length
  const cols = shape[0].length
  const rotated: number[][] = Array.from({ length: cols }, () =>
    new Array(rows).fill(0)
  )

  for (let y = 0; y < rows; y++) {
    for (let x = 0; x < cols; x++) {
      rotated[x][rows - 1 - y] = shape[y][x]
    }
  }

  return rotated
}

Wall-adjacent rotation requires SRS (Super Rotation System) kick detection, but here a simplified approach cancels the rotation if it causes a collision.

Breakout Physics Integration

Breakout uses a simpler custom physics loop on Canvas API rather than the usePhysics hook. With just one ball and one paddle, a general-purpose physics engine is overkill:

function updateBall(ball: Ball, paddle: Paddle, bricks: Brick[], dt: number) {
  ball.x += ball.vx * dt
  ball.y += ball.vy * dt

  // Wall reflection
  if (ball.x - ball.r < 0 || ball.x + ball.r > width) ball.vx *= -1

  // Paddle collision
  if (intersectsRect(ball, paddle)) {
    ball.vy = -Math.abs(ball.vy)
    // Change reflection angle based on hit position on paddle
    const offset = (ball.x - paddle.x) / (paddle.w / 2)
    ball.vx = offset * 5
  }
}

"Choosing the right tool" is also a design decision. A general-purpose physics engine isn't always the right answer.

Summary: Technologies and Tools Used for TypeScript Game Loops

The books in the toolshelf systematically cover how to leverage TypeScript's type system for game development. Type-safe design directly contributes to catching bugs early.