Fundamentos del bucle de juego con Canvas 2D
El bucle de juego es el corazón de tu juego. Se ejecuta continuamente, actualizando el estado del juego y dibujando fotogramas tan rápido como lo permita el navegador. Si lo haces bien, todo lo demás será más fácil. Si lo haces mal, acabarás persiguiendo errores que solo aparecen en determinados equipos.
Pruébalo (usa las teclas de dirección o WASD):
La versión más sencilla
Este es un bucle de juego reducido a lo esencial:
const canvas = document.getElementById('game')
const ctx = canvas.getContext('2d')
function gameLoop() {
update()
render()
requestAnimationFrame(gameLoop)
}
function update() {
// Lógica del juego aquí
}
function render() {
ctx.clearRect(0, 0, canvas.width, canvas.height)
// Dibujo aquí
}
gameLoop()Esto funciona, pero tiene un problema. La función update no sabe cuánto tiempo ha pasado desde el último fotograma. En un monitor de 144 Hz, se ejecuta el doble de rápido que en uno de 60 Hz. La física de tu juego se comportará de forma diferente en distintos equipos.
El tiempo delta soluciona los problemas de frecuencia de fotogramas
Para que tu juego se ejecute a la misma velocidad en todas partes, necesitas registrar cuánto tiempo ha transcurrido entre fotogramas. Esto se denomina tiempo delta y suele abreviarse como dt.
let lastTime = 0
function gameLoop(currentTime) {
const dt = (currentTime - lastTime) / 1000 // convertir a segundos
lastTime = currentTime
update(dt)
render()
requestAnimationFrame(gameLoop)
}
function update(dt) {
player.x += player.speed * dt // ahora funciona con cualquier frecuencia de fotogramas
}
requestAnimationFrame(gameLoop)La función de retorno de requestAnimationFrame recibe la marca de tiempo actual en milisegundos. La divido entre 1000 para obtener segundos porque así los cálculos resultan más sencillos. Una velocidad de 200 significa 200 píxeles por segundo, lo cual es intuitivo.
Hay algo que debes tener en cuenta: en el primer fotograma, lastTime es 0, por lo que dt será enorme. Algunas personas inicializan lastTime con performance.now() antes de iniciar el bucle, mientras que otras omiten la actualización del primer fotograma.
Por qué usar requestAnimationFrame en lugar de setInterval
Quizá te preguntes por qué usamos requestAnimationFrame en lugar de setInterval. Hay varias razones.
En primer lugar, requestAnimationFrame se sincroniza con la frecuencia de actualización de la pantalla, por lo que obtienes una animación fluida y sin desgarros. En segundo lugar, se pausa automáticamente cuando la pestaña está oculta, lo que ahorra batería y CPU. En tercer lugar, el navegador puede optimizarlo mejor.
Ese comportamiento de pausa suele ser lo que buscas, pero significa que el juego se congela cuando el usuario cambia de pestaña. Si estás creando un juego multijugador o algo que necesita seguir ejecutándose, tendrás que emplear otro enfoque.
Paso de tiempo fijo para la física
El tiempo delta variable funciona para movimientos sencillos, pero causa problemas en las simulaciones físicas. Si un fotograma tarda demasiado, tu personaje podría atravesar una pared porque la comprobación de colisiones no detectó el momento del impacto.
La solución es un paso de tiempo fijo. Acumulas tiempo y ejecutas las actualizaciones físicas a una frecuencia fija, independientemente de la velocidad a la que se rendericen los fotogramas.
const FIXED_DT = 1 / 60 // 60 actualizaciones físicas por segundo
let accumulator = 0
let lastTime = 0
function gameLoop(currentTime) {
const frameTime = Math.min((currentTime - lastTime) / 1000, 0.25)
lastTime = currentTime
accumulator += frameTime
while (accumulator >= FIXED_DT) {
update(FIXED_DT)
accumulator -= FIXED_DT
}
render()
requestAnimationFrame(gameLoop)
}Ese Math.min(..., 0.25) es importante. Si la pestaña estaba oculta y vuelve a mostrarse, frameTime podría ser enorme. Sin ese límite, el bucle while ejecutaría cientos de actualizaciones para intentar ponerse al día y congelaría el juego. Limitarlo a 0,25 segundos significa que nunca ejecutarás más de 15 actualizaciones físicas por fotograma.
Cómo organizar el estado del juego
A medida que tu juego crece, necesitas un lugar donde guardar todos los datos. Me gusta utilizar un único objeto que lo contenga todo:
const game = {
state: 'playing', // 'menu', 'playing', 'paused', 'gameover'
score: 0,
player: {
x: 100,
y: 100,
vx: 0,
vy: 0,
speed: 200,
width: 32,
height: 32,
},
entities: [],
}Esto facilita guardar y cargar el estado del juego, reiniciarlo cuando muere el jugador y depurarlo escribiendo game en la consola. Algunas personas prefieren las clases, pero un objeto simple funciona perfectamente para juegos pequeños y medianos.
Uniendo todas las piezas
const canvas = document.getElementById('game')
const ctx = canvas.getContext('2d')
const FIXED_DT = 1 / 60
let accumulator = 0
let lastTime = 0
const input = { left: false, right: false, up: false, down: false }
const game = {
player: { x: 100, y: 100, speed: 200 },
}
function update(dt) {
const p = game.player
if (input.left) p.x -= p.speed * dt
if (input.right) p.x += p.speed * dt
if (input.up) p.y -= p.speed * dt
if (input.down) p.y += p.speed * dt
// Mantener dentro de los límites
p.x = Math.max(0, Math.min(canvas.width - 32, p.x))
p.y = Math.max(0, Math.min(canvas.height - 32, p.y))
}
function render() {
ctx.fillStyle = '#1a1a2e'
ctx.fillRect(0, 0, canvas.width, canvas.height)
ctx.fillStyle = '#4ade80'
ctx.fillRect(game.player.x, game.player.y, 32, 32)
}
function gameLoop(currentTime) {
const frameTime = Math.min((currentTime - lastTime) / 1000, 0.25)
lastTime = currentTime
accumulator += frameTime
while (accumulator >= FIXED_DT) {
update(FIXED_DT)
accumulator -= FIXED_DT
}
render()
requestAnimationFrame(gameLoop)
}
// Gestión de la entrada
window.addEventListener('keydown', (e) => {
if (e.code === 'ArrowLeft' || e.code === 'KeyA') input.left = true
if (e.code === 'ArrowRight' || e.code === 'KeyD') input.right = true
if (e.code === 'ArrowUp' || e.code === 'KeyW') input.up = true
if (e.code === 'ArrowDown' || e.code === 'KeyS') input.down = true
})
window.addEventListener('keyup', (e) => {
if (e.code === 'ArrowLeft' || e.code === 'KeyA') input.left = false
if (e.code === 'ArrowRight' || e.code === 'KeyD') input.right = false
if (e.code === 'ArrowUp' || e.code === 'KeyW') input.up = false
if (e.code === 'ArrowDown' || e.code === 'KeyS') input.down = false
})
requestAnimationFrame(gameLoop)Este es un bucle de juego completo y ejecutable. El jugador se mueve con las teclas de dirección o WASD, permanece dentro de los límites y todo se ejecuta a una velocidad constante independientemente de la frecuencia de fotogramas. Puedes copiarlo como punto de partida para cualquier juego con Canvas 2D.
Observa cómo la gestión de la entrada está separada del bucle de juego. Los escuchadores de eventos se limitan a establecer indicadores y la función de actualización lee esos indicadores. Este desacoplamiento es importante porque los eventos del teclado se activan en momentos impredecibles, mientras que necesitas que la lógica del juego se ejecute a una frecuencia predecible.
Cargar y dibujar sprites
const sprites = {}
async function loadSprite(name, url) {
return new Promise((resolve) => {
const img = new Image()
img.onload = () => {
sprites[name] = img
resolve(img)
}
img.src = url
})
}
function drawSprite(name, x, y, width, height) {
const sprite = sprites[name]
if (sprite) {
ctx.drawImage(sprite, x, y, width || sprite.width, height || sprite.height)
}
}Las imágenes se cargan de forma asíncrona, así que debes esperar a que estén listas antes de iniciar el juego. Un patrón habitual consiste en cargar todos los sprites en una función loadAssets que devuelva una promesa y, después, iniciar el bucle de juego únicamente cuando esta se resuelva.
La función drawSprite comprueba si el sprite existe antes de dibujarlo. Esto evita errores si intentas dibujar algo que aún no se ha cargado.
Sprites animados
class AnimatedSprite {
constructor(image, frameWidth, frameHeight, frameCount, fps = 10) {
this.image = image
this.frameWidth = frameWidth
this.frameHeight = frameHeight
this.frameCount = frameCount
this.frameDuration = 1 / fps
this.currentFrame = 0
this.elapsed = 0
}
update(dt) {
this.elapsed += dt
if (this.elapsed >= this.frameDuration) {
this.currentFrame = (this.currentFrame + 1) % this.frameCount
this.elapsed = 0
}
}
draw(ctx, x, y) {
ctx.drawImage(
this.image,
this.currentFrame * this.frameWidth, 0,
this.frameWidth, this.frameHeight,
x, y,
this.frameWidth, this.frameHeight
)
}
}Esta clase supone que tu hoja de sprites tiene los fotogramas dispuestos horizontalmente en una sola fila. Todos los fotogramas tienen el mismo tamaño. El método update avanza por los fotogramas según el tiempo transcurrido y draw utiliza la versión de nueve argumentos de drawImage para recortar únicamente el fotograma actual.
La usarías así:
const playerWalk = new AnimatedSprite(walkImage, 32, 32, 4, 12)
// En update:
playerWalk.update(dt)
// En render:
playerWalk.draw(ctx, player.x, player.y)Detección de colisiones
function rectCollision(a, b) {
return (
a.x < b.x + b.width &&
a.x + a.width > b.x &&
a.y < b.y + b.height &&
a.y + a.height > b.y
)
}
function circleCollision(a, b) {
const dx = a.x - b.x
const dy = a.y - b.y
const distance = Math.sqrt(dx * dx + dy * dy)
return distance < a.radius + b.radius
}
// En update:
for (const enemy of game.enemies) {
if (rectCollision(game.player, enemy)) {
handleCollision(game.player, enemy)
}
}La colisión entre rectángulos —también llamada AABB, por «caja delimitadora alineada con los ejes»— es la opción más sencilla y rápida. Úsala para la mayoría de los elementos. La colisión entre círculos es mejor para objetos redondos o cuando quieres que las colisiones resulten más permisivas.
Con un número reducido de entidades, no hay problema en comprobar cada par. Si tienes cientos de entidades, necesitarás particionamiento espacial —árboles cuádruples o una cuadrícula—, pero ese es un tema para otro tutorial.
Una cámara sencilla
const camera = {
x: 0,
y: 0,
follow(target) {
this.x = target.x - canvas.width / 2
this.y = target.y - canvas.height / 2
}
}
function render() {
ctx.save()
ctx.translate(-camera.x, -camera.y)
// Dibujar el mundo
drawWorld()
drawEntities()
ctx.restore()
// Dibujar la interfaz de usuario (no afectada por la cámara)
drawUI()
}El truco está en ctx.save() y ctx.restore(). Guardas el estado actual de la transformación, aplicas el desplazamiento de la cámara con translate, dibujas todo en el espacio del mundo y después restauras el estado para que los elementos de la interfaz no se vean afectados por la cámara.
Para conseguir un movimiento de cámara más fluido, puedes interpolar linealmente hacia el objetivo en lugar de saltar directamente hasta él. Algo como this.x += (targetX - this.x) * 0.1 produce ese agradable efecto de seguimiento con retraso.
Pausar el juego
let paused = false
window.addEventListener('keydown', (e) => {
if (e.code === 'Escape') {
paused = !paused
}
})
function gameLoop(currentTime) {
const frameTime = Math.min((currentTime - lastTime) / 1000, 0.25)
lastTime = currentTime
if (!paused) {
accumulator += frameTime
while (accumulator >= FIXED_DT) {
update(FIXED_DT)
accumulator -= FIXED_DT
}
}
render()
if (paused) {
renderPauseOverlay()
}
requestAnimationFrame(gameLoop)
}
function renderPauseOverlay() {
ctx.fillStyle = 'rgba(0, 0, 0, 0.5)'
ctx.fillRect(0, 0, canvas.width, canvas.height)
ctx.fillStyle = '#fff'
ctx.font = '32px sans-serif'
ctx.textAlign = 'center'
ctx.fillText('EN PAUSA', canvas.width / 2, canvas.height / 2)
}La idea clave es que dejas de acumular tiempo durante la pausa, pero sigues renderizando. De este modo, la superposición de pausa aparece de inmediato y el juego sigue respondiendo a la entrada para reanudarlo.
Podrías ampliar este patrón para gestionar varios estados del juego, como menús, escenas cinemáticas y pantallas de fin de partida. Basta con comprobar el estado actual y dirigir la ejecución a distintas funciones de actualización y renderizado.
Errores comunes
Estas son algunas cosas que suelen causar problemas:
Olvidarse de limpiar el lienzo. Si no llamas a clearRect al principio del renderizado, dibujarás encima del fotograma anterior y aparecerán estelas.
No limitar el tiempo delta. Si alguien cambia de pestaña y vuelve, un tiempo delta enorme puede romper la física o hacer que el juego intente «ponerse al día» durante varios segundos.
Dibujar antes de que se carguen las imágenes. Canvas ignora silenciosamente las llamadas a drawImage con imágenes que todavía no se han cargado. Tus sprites simplemente no aparecerán y no recibirás ningún mensaje de error.
Usar setInterval para el bucle. No se sincroniza con la pantalla ni se pausa cuando la pestaña está oculta. Usa requestAnimationFrame.
Mezclar coordenadas del mundo y de la pantalla. Cuando añadas una cámara, lleva un control de si una posición está en el espacio del mundo o en el de la pantalla. Los clics del ratón llegan en coordenadas de pantalla y deben convertirse.
Próximos pasos
Este tutorial abarca los fundamentos. Cuando te sientas cómodo con estos patrones, quizá quieras explorar:
Canvas adaptable para juegos para gestionar distintos tamaños de pantalla y pantallas con alta densidad de píxeles.
Gestión de la entrada en juegos para controles táctiles, mandos y sistemas de entrada más sofisticados.
Renderizado de pixel art para obtener gráficos de píxeles nítidos y sin desenfoque.
Bibliotecas de física para juegos para añadir física de cuerpos rígidos, colisiones y articulaciones a tu bucle de juego.
Si estás decidiendo entre Canvas 2D, WebGL y WebGPU para tu proyecto, la guía Tecnologías para juegos web en 2026 explica sus ventajas y desventajas. La Comparativa de motores de juegos web analiza los frameworks que gestionan el bucle de juego por ti.
Recursos externos
- MDN: requestAnimationFrame — referencia de la API del navegador
- MDN: API de Canvas — documentación completa de Canvas 2D
- MDN: KeyboardEvent — códigos de teclas y propiedades de eventos
- ¡Corrige tu paso de tiempo! — el clásico artículo de Glenn Fiedler sobre bucles de juego con paso de tiempo fijo