Canvas de juego adaptable
Un canvas que no escala correctamente se ve borroso o se rompe en distintos tamaños de pantalla. He depurado este problema más veces de las que puedo contar, así que déjame ahorrarte el esfuerzo.
Prueba distintos modos de escalado:
El problema principal
El canvas tiene dos tamaños que son fáciles de confundir. El tamaño CSS controla lo grande que aparece en pantalla. La resolución interna controla cuántos píxeles dibujas realmente. Si no coinciden, se produce desenfoque o distorsión.
En una pantalla Retina, tu CSS puede indicar 800x600, pero la pantalla tiene en realidad 1600x1200 píxeles físicos. Si la resolución interna de tu canvas solo es de 800x600, el navegador la amplía y todo se ve borroso.
Configuración adaptable básica
Así puedes hacer que un canvas ocupe toda la ventana y se vea nítido:
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
// Ajusta la resolución interna para que coincida con los píxeles físicos
canvas.width = width * dpr
canvas.height = height * dpr
// Ajusta el tamaño CSS para que coincida con los píxeles lógicos
canvas.style.width = width + 'px'
canvas.style.height = height + 'px'
// Escala el contexto para poder dibujar en píxeles lógicos
ctx.scale(dpr, dpr)
}
window.addEventListener('resize', resize)
resize()La línea ctx.scale(dpr, dpr) es el truco. Después, puedes dibujar en la posición (100, 100) y aparecerá en el lugar correcto independientemente del DPR. El canvas se renderiza internamente a 200x200 en una pantalla 2x, pero tu código no necesita saberlo.
Un detalle importante: ctx.scale es acumulativo. Si llamas varias veces a la función de cambio de tamaño, el escalado seguirá aumentando. Puedes restablecer primero la transformación con ctx.setTransform(1, 0, 0, 1, 0, 0) antes de escalar o aplicar el escalado una sola vez.
Relación de aspecto fija con barras negras
Muchos juegos necesitan una relación de aspecto específica. No querrás que tu juego 16:9 se estire para llenar un monitor ultrapanorámico 21:9. Las barras negras conservan la relación de aspecto.
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) {
// La ventana es más ancha que el juego; añade barras a los lados
height = windowHeight
width = height * ASPECT
} else {
// La ventana es más alta que el juego; añade barras arriba y abajo
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'
// La resolución interna permanece fija
canvas.width = GAME_WIDTH
canvas.height = GAME_HEIGHT
}Para que se vean las barras negras, establece en negro el fondo del cuerpo de la página:
body {
margin: 0;
background: #000;
overflow: hidden;
}Escalado entero para pixel art
El pixel art se ve nítido con escalas enteras (1x, 2x, 3x) y borroso con escalas fraccionarias como 2,7x. Incluso con image-rendering: pixelated, una escala de 2,5x hace que algunos píxeles tengan 2 píxeles de pantalla de ancho y otros 3.
const GAME_WIDTH = 320
const GAME_HEIGHT = 180
function resizePixelPerfect() {
const maxWidth = window.innerWidth
const maxHeight = window.innerHeight
// Encuentra la mayor escala entera que quepa
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'
}La contrapartida es que tu juego no llenará la pantalla exactamente. En una pantalla de 1920x1080 con un juego de 320x180, obtendrás un escalado de 5x (1600x900) con barras negras alrededor de los bordes. Algunos jugadores lo prefieren; otros quieren que el juego llene la pantalla aunque se vea ligeramente borroso.
Escalado del canvas WebGL
Para WebGL, además del tamaño del canvas debes actualizar el viewport:
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 // Se ha redimensionado; quizá debas actualizar las matrices de proyección
}
return false
}Llama a esta función al inicio del bucle de renderizado, no solo en los eventos de cambio de tamaño. Algunos navegadores cambian el DPR cuando arrastras una ventana entre monitores, y conviene detectar ese cambio.
Limitación de eventos de cambio de tamaño
Los eventos de cambio de tamaño se disparan rápidamente mientras el usuario arrastra el borde de la ventana. Si tu función de cambio de tamaño es costosa, se producirán tirones.
let resizeTimeout
window.addEventListener('resize', () => {
clearTimeout(resizeTimeout)
resizeTimeout = setTimeout(resize, 100)
})El retraso de 100 ms hace que el cambio de tamaño solo se produzca cuando el usuario deja de arrastrar. Para gestionarlo con mayor fluidez durante el arrastre, usa ResizeObserver:
const observer = new ResizeObserver(entries => {
resize()
})
observer.observe(document.body)ResizeObserver se activa con menos frecuencia y está mejor optimizado por el navegador.
Pantalla completa
La API Fullscreen permite ocupar toda la pantalla:
async function toggleFullscreen() {
if (!document.fullscreenElement) {
await canvas.requestFullscreen()
} else {
await document.exitFullscreen()
}
}
document.addEventListener('fullscreenchange', resize)Debes llamar a requestFullscreen en respuesta a un gesto del usuario, como un clic. De lo contrario, los navegadores lo bloquean. Y recuerda cambiar el tamaño al entrar o salir del modo de pantalla completa, ya que el espacio disponible cambia.
Una salvedad sobre las plataformas: a mediados de 2026, la API Fullscreen aún no permite mostrar elementos a pantalla completa en Safari para iPhone. requestFullscreen funciona en navegadores de escritorio y en iPadOS, pero en iPhone no es compatible con elementos arbitrarios como un canvas (solo funciona la reproducción nativa de vídeo a pantalla completa). Detecta la función con if (canvas.requestFullscreen) y ofrece una alternativa adecuada, por ejemplo, ajustando el canvas para que ocupe el viewport visible o sugiriendo a los usuarios que añadan el juego a su pantalla de inicio como PWA.
Unirlo todo
Esta clase gestiona todos los casos habituales:
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 }
}Úsala así:
const responsive = new ResponsiveCanvas(document.getElementById('game'), {
width: 320,
height: 180,
pixelArt: true,
letterbox: true
})
// En el código de tu juego, usa responsive.width y responsive.heightProblemas habituales
Algunas cosas que suelen causar problemas:
El canvas se borra al cambiar su tamaño. Cuando estableces canvas.width o canvas.height, el canvas se borra. Si no vuelves a dibujarlo en cada fotograma, tendrás que hacerlo después de cambiar su tamaño.
El DPR puede cambiar. Si el usuario arrastra la ventana del navegador desde una pantalla Retina a una pantalla que no sea Retina, el DPR cambia. Compruébalo en el bucle de renderizado, no solo al cambiar el tamaño.
Peculiaridades del viewport en Safari para iOS. La altura del viewport cambia cuando la barra de direcciones aparece o desaparece. Usa window.visualViewport para obtener medidas más precisas en dispositivos móviles.
El estado del contexto se restablece. Al establecer las dimensiones del canvas, se restablece el estado del contexto, incluidas las transformaciones, los estilos de relleno e imageSmoothingEnabled. Vuelve a aplicarlos después de cambiar el tamaño.
Más recursos
Bucle de juego con Canvas 2D explica los fundamentos de los bucles de actualización y renderizado.
Renderizado de pixel art profundiza en cómo crear gráficos de píxeles nítidos.
Juegos web adaptados a dispositivos móviles trata la entrada táctil y los problemas del viewport.
Fundamentos de WebGL explica la configuración del viewport y del canvas para contextos WebGL.
Publica un juego web que cargue rápido explica cómo optimizar el tamaño del canvas para mejorar el rendimiento.