Skip to content

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:

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
  
  // 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.

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) {
    // 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:

css
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.

js
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:

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 // 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.

js
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:

js
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:

js
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:

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 }
}

Úsala así:

js
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.height

Problemas 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.