Skip to content

Responsive Game Canvas

A canvas that doesn't scale properly looks blurry or breaks on different screen sizes. I've debugged this issue more times than I can count, so let me save you the trouble.

Try different scaling modes:

The Core Problem

Canvas has two sizes that are easy to confuse. The CSS size controls how big it appears on screen. The internal resolution controls how many pixels you're actually drawing. If these don't match, you get blur or distortion.

On a Retina display, your CSS might say 800x600, but the screen actually has 1600x1200 physical pixels. If your canvas internal resolution is only 800x600, the browser scales it up and everything looks fuzzy.

Basic Responsive Setup

Here's how to make a canvas fill the window and look sharp:

js
const canvas = document.getElementById('game')
const ctx = canvas.getContext('2d')

function resize() {
  const dpr = window.devicePixelRatio || 1
  const width = window.innerWidth
  const height = window.innerHeight
  
  // Set internal resolution to match physical pixels
  canvas.width = width * dpr
  canvas.height = height * dpr
  
  // Set CSS size to match logical pixels
  canvas.style.width = width + 'px'
  canvas.style.height = height + 'px'
  
  // Scale context so you can draw in logical pixels
  ctx.scale(dpr, dpr)
}

window.addEventListener('resize', resize)
resize()

The ctx.scale(dpr, dpr) line is the trick. After that, you can draw at position (100, 100) and it shows up at the right place regardless of DPR. The canvas internally renders at 200x200 on a 2x display, but your code doesn't need to know that.

One gotcha: ctx.scale is cumulative. If you call resize multiple times, you'll keep scaling up. Either reset the transform first with ctx.setTransform(1, 0, 0, 1, 0, 0) before scaling, or only scale once.

Fixed Aspect Ratio with Letterboxing

Many games need a specific aspect ratio. You don't want your 16:9 game stretched to fill a 21:9 ultrawide monitor. Letterboxing adds black bars to preserve the ratio.

js
const GAME_WIDTH = 1920
const GAME_HEIGHT = 1080
const ASPECT = GAME_WIDTH / GAME_HEIGHT

function resizeWithLetterbox() {
  const windowWidth = window.innerWidth
  const windowHeight = window.innerHeight
  const windowAspect = windowWidth / windowHeight
  
  let width, height
  
  if (windowAspect > ASPECT) {
    // Window is wider than game, add bars on sides
    height = windowHeight
    width = height * ASPECT
  } else {
    // Window is taller than game, add bars on top/bottom
    width = windowWidth
    height = width / ASPECT
  }
  
  canvas.style.width = width + 'px'
  canvas.style.height = height + 'px'
  canvas.style.position = 'absolute'
  canvas.style.left = (windowWidth - width) / 2 + 'px'
  canvas.style.top = (windowHeight - height) / 2 + 'px'
  
  // Internal resolution stays fixed
  canvas.width = GAME_WIDTH
  canvas.height = GAME_HEIGHT
}

For the letterbox bars to show, set your body background to black:

css
body {
  margin: 0;
  background: #000;
  overflow: hidden;
}

Integer Scaling for Pixel Art

Pixel art looks crisp at integer scales (1x, 2x, 3x) and blurry at fractional scales like 2.7x. Even with image-rendering: pixelated, a 2.5x scale means some pixels are 2 screen pixels wide and others are 3.

js
const GAME_WIDTH = 320
const GAME_HEIGHT = 180

function resizePixelPerfect() {
  const maxWidth = window.innerWidth
  const maxHeight = window.innerHeight
  
  // Find largest integer scale that fits
  const scaleX = Math.floor(maxWidth / GAME_WIDTH)
  const scaleY = Math.floor(maxHeight / GAME_HEIGHT)
  const scale = Math.max(1, Math.min(scaleX, scaleY))
  
  const width = GAME_WIDTH * scale
  const height = GAME_HEIGHT * scale
  
  canvas.width = GAME_WIDTH
  canvas.height = GAME_HEIGHT
  canvas.style.width = width + 'px'
  canvas.style.height = height + 'px'
  canvas.style.imageRendering = 'pixelated'
}

The trade-off is that your game won't fill the screen exactly. On a 1920x1080 display with a 320x180 game, you get 5x scaling (1600x900) with black bars around the edges. Some players prefer this; others want the game to fill their screen even if it's slightly blurry.

WebGL Canvas Scaling

For WebGL, you need to update the viewport in addition to the canvas size:

js
function resizeGL(gl, canvas) {
  const dpr = window.devicePixelRatio || 1
  const width = canvas.clientWidth * dpr
  const height = canvas.clientHeight * dpr
  
  if (canvas.width !== width || canvas.height !== height) {
    canvas.width = width
    canvas.height = height
    gl.viewport(0, 0, width, height)
    return true // Resized, you may need to update projection matrices
  }
  return false
}

Call this at the start of your render loop, not just on resize events. Some browsers change DPR when you drag a window between monitors, and you want to catch that.

Debouncing Resize Events

Resize events fire rapidly while the user drags the window edge. If your resize function is expensive, you'll get jank.

js
let resizeTimeout
window.addEventListener('resize', () => {
  clearTimeout(resizeTimeout)
  resizeTimeout = setTimeout(resize, 100)
})

The 100ms delay means the resize only happens after the user stops dragging. For smoother handling during the drag, use ResizeObserver instead:

js
const observer = new ResizeObserver(entries => {
  resize()
})
observer.observe(document.body)

ResizeObserver fires less frequently and is better optimized by the browser.

Fullscreen

The Fullscreen API lets you take over the whole screen:

js
async function toggleFullscreen() {
  if (!document.fullscreenElement) {
    await canvas.requestFullscreen()
  } else {
    await document.exitFullscreen()
  }
}

document.addEventListener('fullscreenchange', resize)

You must call requestFullscreen in response to a user gesture like a click. Browsers block it otherwise. And remember to resize when entering/exiting fullscreen since the available space changes.

Putting It Together

Here's a class that handles all the common cases:

js
class ResponsiveCanvas {
  constructor(canvas, options = {}) {
    this.canvas = canvas
    this.ctx = canvas.getContext('2d')
    this.gameWidth = options.width || 1280
    this.gameHeight = options.height || 720
    this.pixelArt = options.pixelArt || false
    this.letterbox = options.letterbox !== false
    
    this.bind()
    this.resize()
  }
  
  bind() {
    window.addEventListener('resize', () => this.resize())
    document.addEventListener('fullscreenchange', () => this.resize())
  }
  
  resize() {
    const aspect = this.gameWidth / this.gameHeight
    let width = window.innerWidth
    let height = window.innerHeight
    
    if (this.letterbox) {
      if (width / height > aspect) {
        width = height * aspect
      } else {
        height = width / aspect
      }
    }
    
    if (this.pixelArt) {
      const scale = Math.max(1, Math.floor(Math.min(
        window.innerWidth / this.gameWidth,
        window.innerHeight / this.gameHeight
      )))
      width = this.gameWidth * scale
      height = this.gameHeight * scale
    }
    
    this.canvas.width = this.gameWidth
    this.canvas.height = this.gameHeight
    this.canvas.style.width = width + 'px'
    this.canvas.style.height = height + 'px'
    this.canvas.style.position = 'absolute'
    this.canvas.style.left = (window.innerWidth - width) / 2 + 'px'
    this.canvas.style.top = (window.innerHeight - height) / 2 + 'px'
    
    if (this.pixelArt) {
      this.canvas.style.imageRendering = 'pixelated'
    }
  }
  
  get width() { return this.gameWidth }
  get height() { return this.gameHeight }
}

Use it like this:

js
const responsive = new ResponsiveCanvas(document.getElementById('game'), {
  width: 320,
  height: 180,
  pixelArt: true,
  letterbox: true
})

// In your game code, use responsive.width and responsive.height

Common Gotchas

A few things that catch people:

Canvas clears on resize. When you set canvas.width or canvas.height, the canvas clears. If you're not redrawing every frame, you need to redraw after resize.

DPR can change. If the user drags the browser window from a Retina display to a non-Retina display, DPR changes. Check it in your render loop, not just on resize.

iOS Safari viewport quirks. The viewport height changes when the address bar shows/hides. Use window.visualViewport for more accurate sizing on mobile.

Context state resets. Setting canvas dimensions resets context state including transforms, fill styles, and imageSmoothingEnabled. Reapply them after resize.

More Resources

Canvas 2D game loop covers the fundamentals of update/render loops.

Pixel art rendering goes deeper on crisp pixel graphics.

Mobile-friendly web games covers touch input and viewport issues.