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:
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.
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:
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.
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:
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.
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:
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:
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:
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:
const responsive = new ResponsiveCanvas(document.getElementById('game'), {
width: 320,
height: 180,
pixelArt: true,
letterbox: true
})
// In your game code, use responsive.width and responsive.heightCommon 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.