Designing a Type-Safe Game Loop in TypeScript — State Management for Snake and Tetris
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.
Next reads
Pinball Physics Simulation — Implementing Flippers, Bumpers, and Gravity with Canvas API
Implementing pinball physics with Canvas API. Covers flipper rotational motion, bumper restitution, gravity, and ball behavior.
Browser Game Developer Setup 2026 — The Toolchain Behind 203 Components
A tour of the development environment for building 203 browser games and interactions. Claude Code-centric workflow, bun + Biome + Vite, and parallel development with Git Worktree.
Building a Breakout Game in the Browser — Mastering Reflection Angles and Level Design with Canvas API
Implementing a Breakout game using Canvas API and requestAnimationFrame. Covers paddle reflection angle calculation, block layout level design, and score management through component implementation.
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.