Por qué construir un juego de física sin librerías
Los motores de videojuegos modernos resuelven casi todo por nosotros: física, colisiones, animación, entrada del usuario y gestión de escenas. Esa comodidad tiene un costo poco visible, porque cuando algo falla es difícil saber qué ocurre dentro de la caja negra. Programar un juego desde cero con el navegador como única plataforma obliga a entender cada pieza, y ese conocimiento se transfiere después a cualquier motor o framework.
En este tutorial vamos a construir un juego 2D con JavaScript puro utilizando solo HTML, CSS y la API Canvas. No instalaremos dependencias, no usaremos empaquetadores y no habrá ningún paso de compilación. Todo lo que necesitas es un editor de código y un navegador actualizado.
A lo largo del artículo implementaremos un bucle de juego independiente de la velocidad del monitor, detección y respuesta de colisiones entre círculos, animación a partir de hojas de sprites, captura del puntero, una inteligencia artificial sencilla para los enemigos, temporizadores y un sistema de reinicio. Al final también revisaremos errores frecuentes, técnicas de rendimiento y cuándo conviene dar el salto a una librería especializada.
Qué vamos a construir
El juego se llama “Guardián del Jardín”. El jugador controla a un personaje circular que sigue la posición del puntero. En el escenario aparecen semillas que deben llegar a una zona segura situada en la parte superior de la pantalla. Mientras tanto, unos escarabajos recorren el jardín y, si tocan una semilla, la destruyen. El jugador no puede recoger semillas: solo puede empujarlas con su cuerpo, y también puede empujar a los escarabajos para apartarlos.
Esta mecánica es interesante porque todo gira alrededor de la física. Las semillas, el jugador y los escarabajos son círculos con posición, velocidad y masa, y la jugabilidad surge de cómo se empujan entre sí. Es una idea sencilla de explicar y suficientemente rica para practicar todos los conceptos importantes.
Las reglas son las siguientes:
- Cada semilla que llega a la zona segura suma un punto.
- Cada semilla destruida por un escarabajo resta una vida.
- La partida termina al alcanzar la puntuación objetivo o al perder todas las vidas.
- La tecla R reinicia la partida en cualquier momento.
Estructura del proyecto
Mantendremos el código separado en módulos ES nativos. Los navegadores actuales cargan módulos con type="module" sin necesidad de herramientas adicionales, lo que nos permite organizar el proyecto como una aplicación real.
guardian-del-jardin/
├── index.html
├── style.css
├── assets/
│ ├── jugador.png
│ ├── semilla.png
│ ├── escarabajo.png
│ └── fondo.png
└── src/
├── main.js
├── game.js
├── input.js
├── vector.js
├── entities/
│ ├── body.js
│ ├── player.js
│ ├── seed.js
│ └── beetle.js
└── utils/
├── sprite.js
└── timer.js
Un detalle importante: los módulos ES no funcionan si abres el archivo directamente con el protocolo file://, porque el navegador bloquea su carga por motivos de seguridad. Necesitas un servidor local. Cualquiera de estas opciones sirve:
# Con Node.js instalado
npx serve .
# Con Python 3
python3 -m http.server 8080
Serving HTTP on 0.0.0.0 port 8080 (http://0.0.0.0:8080/) ...
También puedes usar la extensión de servidor en vivo de tu editor. Lo relevante es que la página se sirva por HTTP.
Preparar el lienzo con HTML y CSS
El HTML es mínimo. Solo necesitamos un elemento canvas, un botón para reiniciar y la carga del módulo principal.
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Guardián del Jardín</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="stage">
<canvas id="juego" aria-label="Juego Guardián del Jardín"></canvas>
<button id="reiniciar" type="button">Reiniciar</button>
</main>
<script type="module" src="src/main.js"></script>
</body>
</html>
En CSS nos interesa centrar el lienzo, evitar que el navegador interprete los gestos táctiles como desplazamiento y mantener la proporción del área de juego.
* {
box-sizing: border-box;
margin: 0;
}
body {
min-height: 100dvh;
display: grid;
place-items: center;
background: #1b2a1f;
font-family: system-ui, sans-serif;
}
.stage {
position: relative;
width: min(100vw, 1280px);
aspect-ratio: 16 / 9;
}
#juego {
width: 100%;
height: 100%;
display: block;
touch-action: none;
cursor: crosshair;
}
#reiniciar {
position: absolute;
top: 1rem;
right: 1rem;
padding: 0.5rem 1rem;
border: 0;
border-radius: 0.5rem;
cursor: pointer;
}
La propiedad touch-action: none es clave en dispositivos táctiles: sin ella, arrastrar el dedo sobre el lienzo desplazaría la página en lugar de mover al personaje. La unidad dvh tiene en cuenta las barras dinámicas de los navegadores móviles, algo que vh no hace.
Conviene distinguir entre el tamaño visual del lienzo, que define CSS, y su resolución interna, que definen los atributos width y height. Si solo cambias el tamaño con CSS, el navegador estira los píxeles y todo se ve borroso. Más adelante resolveremos esto teniendo en cuenta la densidad de píxeles de la pantalla.
Un pequeño ayudante para vectores
Casi toda la física del juego consiste en sumar, restar y escalar pares de números. Una clase de vector evita repetir operaciones y hace que el código se lea casi como una fórmula.
// src/vector.js
export class Vec2 {
constructor(x = 0, y = 0) {
this.x = x;
this.y = y;
}
add(v) {
return new Vec2(this.x + v.x, this.y + v.y);
}
sub(v) {
return new Vec2(this.x - v.x, this.y - v.y);
}
scale(s) {
return new Vec2(this.x * s, this.y * s);
}
dot(v) {
return this.x * v.x + this.y * v.y;
}
length() {
return Math.hypot(this.x, this.y);
}
normalize() {
const len = this.length();
return len === 0 ? new Vec2() : this.scale(1 / len);
}
}
Esta versión es inmutable: cada operación devuelve un vector nuevo. Es más fácil de razonar y suficientemente rápida para decenas de entidades. Si el juego creciera a miles de objetos, podríamos cambiar a operaciones que modifiquen el vector existente para reducir la presión sobre el recolector de basura.
El bucle de juego y el tiempo delta
Todo videojuego se basa en un ciclo que se repite muchas veces por segundo: leer la entrada, actualizar el estado del mundo y dibujar el resultado. En el navegador, la forma correcta de implementar ese ciclo es con requestAnimationFrame, que sincroniza nuestras actualizaciones con el refresco de la pantalla y se pausa automáticamente cuando la pestaña no está visible.
Un bucle de juego con requestAnimationFrame recibe en cada llamada una marca de tiempo en milisegundos. Con ella calculamos el tiempo delta, es decir, cuánto ha pasado desde el fotograma anterior.
// src/main.js
import { Game } from "./game.js";
const canvas = document.getElementById("juego");
const ctx = canvas.getContext("2d");
const game = new Game(canvas, ctx);
let lastTime = performance.now();
function loop(now) {
const dt = Math.min((now - lastTime) / 1000, 0.1); // segundos, con tope
lastTime = now;
game.update(dt);
game.draw();
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
document
.getElementById("reiniciar")
.addEventListener("click", () => game.reset());
¿Por qué es tan importante el tiempo delta? Hoy conviven monitores de 60, 120, 144 y hasta 240 Hz. Si movemos un objeto “5 píxeles por fotograma”, en un monitor de 144 Hz se moverá más del doble de rápido que en uno de 60 Hz. Al expresar las velocidades en píxeles por segundo y multiplicarlas por dt, el juego se comporta igual en cualquier pantalla.
El tope de 0,1 segundos protege contra un problema habitual. Cuando el usuario cambia de pestaña, el navegador deja de llamar a requestAnimationFrame. Al volver, la diferencia de tiempo puede ser de varios segundos y, sin tope, todos los objetos saltarían de golpe, atravesando paredes y otros cuerpos.
Controlar la tasa de fotogramas con un paso fijo
El tiempo delta variable funciona bien para el movimiento simple, pero la física es más delicada. Las colisiones y la fricción producen resultados ligeramente distintos según el tamaño del paso de tiempo, lo que puede provocar que el juego se sienta diferente en cada equipo. La solución profesional es el paso fijo: la física siempre avanza en incrementos idénticos, mientras que el dibujado ocurre tan rápido como permita la pantalla.
Implementar un control de la tasa de fotogramas con paso fijo requiere un acumulador de tiempo:
// src/main.js (versión con paso fijo)
const STEP = 1 / 60; // la física avanza a 60 Hz
let accumulator = 0;
let lastTime = performance.now();
function loop(now) {
accumulator += Math.min((now - lastTime) / 1000, 0.25);
lastTime = now;
while (accumulator >= STEP) {
game.update(STEP);
accumulator -= STEP;
}
game.draw();
requestAnimationFrame(loop);
}
Si la pantalla funciona a 120 Hz, algunos fotogramas no ejecutarán ninguna actualización física y otros ejecutarán una. Si el equipo es lento y un fotograma tarda 33 milisegundos, se ejecutarán dos pasos de física seguidos. El resultado es una simulación determinista y estable. Para juegos muy exigentes visualmente se puede añadir interpolación entre el estado anterior y el actual, pero para nuestro proyecto el paso fijo es suficiente.
En ocasiones también se quiere limitar deliberadamente las actualizaciones de una animación concreta, por ejemplo para que un sprite cambie de cuadro solo doce veces por segundo. Veremos esa técnica en la sección de sprites.
Capturar el puntero del usuario
Para que el personaje siga al ratón o al dedo usaremos Pointer Events, la API unificada que cubre ratón, pantalla táctil y lápiz con el mismo código. Ya no es necesario escribir manejadores separados para mousemove y touchmove.
El detalle más importante es convertir las coordenadas del evento, que están en píxeles de la ventana, a las coordenadas internas del juego. Como el lienzo se escala con CSS, ambas no coinciden.
// src/input.js
import { Vec2 } from "./vector.js";
export class Input {
constructor(canvas, game) {
this.pointer = new Vec2(game.width / 2, game.height / 2);
this.pressed = false;
const toGame = (event) => {
const rect = canvas.getBoundingClientRect();
return new Vec2(
((event.clientX - rect.left) / rect.width) * game.width,
((event.clientY - rect.top) / rect.height) * game.height
);
};
canvas.addEventListener("pointerdown", (e) => {
this.pressed = true;
this.pointer = toGame(e);
canvas.setPointerCapture(e.pointerId);
});
canvas.addEventListener("pointermove", (e) => {
this.pointer = toGame(e);
});
canvas.addEventListener("pointerup", () => {
this.pressed = false;
});
window.addEventListener("keydown", (e) => {
if (e.key.toLowerCase() === "r") game.reset();
});
}
}
El método setPointerCapture garantiza que sigamos recibiendo eventos de movimiento aunque el puntero salga del lienzo mientras está presionado, un comportamiento que evita personajes “atascados” cuando el usuario arrastra con rapidez.
Modelar las entidades con clases
Todas las entidades del juego comparten la misma base física: un círculo con posición, velocidad, radio y masa. Una clase base concentra ese comportamiento común y deja a cada subclase solo lo que la hace única.
// src/entities/body.js
import { Vec2 } from "../vector.js";
export class Body {
constructor({ x, y, radius, mass = 1, friction = 0.9 }) {
this.pos = new Vec2(x, y);
this.vel = new Vec2();
this.radius = radius;
this.mass = mass;
this.friction = friction; // fracción de velocidad que se conserva por segundo
this.alive = true;
}
integrate(dt) {
this.pos = this.pos.add(this.vel.scale(dt));
this.vel = this.vel.scale(Math.pow(this.friction, dt));
}
drawDebug(ctx) {
ctx.beginPath();
ctx.arc(this.pos.x, this.pos.y, this.radius, 0, Math.PI * 2);
ctx.stroke();
}
}
El método integrate aplica la integración de Euler: la nueva posición es la anterior más la velocidad multiplicada por el tiempo. La fricción usa Math.pow(friction, dt) en lugar de multiplicar directamente por una constante, de modo que la desaceleración sea la misma sin importar el tamaño del paso.
El jugador es un cuerpo pesado que acelera hacia el puntero:
// src/entities/player.js
import { Body } from "./body.js";
export class Player extends Body {
constructor(game) {
super({
x: game.width / 2,
y: game.height / 2,
radius: 40,
mass: 5,
friction: 0.02,
});
this.game = game;
this.speed = 9; // factor de seguimiento
}
update(dt) {
const target = this.game.input.pointer;
const toTarget = target.sub(this.pos);
// La velocidad es proporcional a la distancia: llega suave y se detiene
this.vel = toTarget.scale(this.speed);
this.integrate(dt);
}
}
Hacer que la velocidad sea proporcional a la distancia produce un movimiento muy natural: el personaje acelera cuando el puntero está lejos y frena con suavidad al acercarse, sin oscilar alrededor del objetivo.
Detección de colisiones entre círculos
Los círculos son la forma más agradecida para la física de un juego. La detección de colisiones entre círculos se reduce a una comparación: dos círculos se tocan si la distancia entre sus centros es menor que la suma de sus radios.
// src/game.js (fragmento)
function checkCollision(a, b) {
const delta = b.pos.sub(a.pos);
const distance = delta.length();
const sumRadii = a.radius + b.radius;
return {
colliding: distance < sumRadii,
distance,
sumRadii,
normal: distance === 0 ? new Vec2(1, 0) : delta.scale(1 / distance),
};
}
Devolvemos más información que un simple verdadero o falso, porque la respuesta física necesitará la distancia y la dirección del choque. La normal es el vector unitario que apunta del centro de a al centro de b. El caso especial de distancia cero evita una división entre cero cuando dos cuerpos aparecen exactamente en el mismo punto.
Una optimización habitual es comparar los cuadrados de las distancias para evitar la raíz cuadrada. En nuestro caso necesitamos la distancia real para la respuesta, así que la calculamos una sola vez y la reutilizamos.
Para una cantidad pequeña de entidades basta con comparar cada par. Con n cuerpos son n × (n − 1) / 2 comparaciones; con 30 cuerpos hablamos de 435, una cifra que cualquier navegador resuelve sin esfuerzo en cada paso.
Respuesta física: separar y rebotar
Detectar el choque es solo la mitad del trabajo. Ahora hay que decidir qué ocurre. Nuestra respuesta tiene dos fases: primero separamos los cuerpos para que dejen de solaparse y después ajustamos sus velocidades para simular el rebote.
// src/game.js (fragmento)
function resolveCollision(a, b, restitution = 0.6) {
const { colliding, distance, sumRadii, normal } = checkCollision(a, b);
if (!colliding) return false;
// 1. Corrección de posición proporcional a la masa
const overlap = sumRadii - distance;
const totalMass = a.mass + b.mass;
a.pos = a.pos.sub(normal.scale(overlap * (b.mass / totalMass)));
b.pos = b.pos.add(normal.scale(overlap * (a.mass / totalMass)));
// 2. Impulso a lo largo de la normal
const relativeVel = b.vel.sub(a.vel);
const velAlongNormal = relativeVel.dot(normal);
if (velAlongNormal > 0) return true; // ya se están separando
const impulse =
(-(1 + restitution) * velAlongNormal) / (1 / a.mass + 1 / b.mass);
const impulseVec = normal.scale(impulse);
a.vel = a.vel.sub(impulseVec.scale(1 / a.mass));
b.vel = b.vel.add(impulseVec.scale(1 / b.mass));
return true;
}
La corrección de posición reparte el solapamiento según la masa: el cuerpo ligero se desplaza más que el pesado. Por eso el jugador, con masa 5, empuja con facilidad a las semillas, con masa 1, mientras que ellas apenas lo mueven.
El impulso se basa en la conservación del momento lineal. El coeficiente de restitución controla cuánta energía se conserva en el choque: con 1 el rebote es perfectamente elástico, con 0 los cuerpos quedan pegados. Un valor de 0,6 da una sensación de semillas ligeras pero no saltarinas. La comprobación de velAlongNormal > 0 evita aplicar impulsos a cuerpos que ya se alejan, lo que provocaría vibraciones extrañas.
Hay un matiz en nuestro jugador: como su velocidad se recalcula en cada paso a partir del puntero, el impulso que recibe se pierde inmediatamente. Eso es intencionado, porque queremos que el control sea preciso y que el personaje no salga despedido al chocar.
Límites del escenario y zona segura
Las semillas y los escarabajos deben permanecer dentro del jardín. Cuando un cuerpo alcanza un borde, lo recolocamos dentro y invertimos la componente de la velocidad perpendicular a esa pared.
// src/game.js (fragmento)
function keepInBounds(body, width, height, bounce = 0.5) {
if (body.pos.x < body.radius) {
body.pos.x = body.radius;
body.vel.x = Math.abs(body.vel.x) * bounce;
} else if (body.pos.x > width - body.radius) {
body.pos.x = width - body.radius;
body.vel.x = -Math.abs(body.vel.x) * bounce;
}
if (body.pos.y > height - body.radius) {
body.pos.y = height - body.radius;
body.vel.y = -Math.abs(body.vel.y) * bounce;
}
}
No limitamos el borde superior para las semillas, porque precisamente es por ahí por donde entran a la zona segura. Usar Math.abs en lugar de multiplicar por -1 evita que un cuerpo quede atrapado oscilando fuera del borde si en un paso no consigue regresar del todo.
La zona segura es simplemente una franja en la parte superior. Cuando el centro de una semilla la alcanza, la marcamos como salvada:
// src/entities/seed.js
import { Body } from "./body.js";
export class Seed extends Body {
constructor(game, x, y) {
super({ x, y, radius: 22, mass: 1, friction: 0.15 });
this.game = game;
this.saved = false;
}
update(dt) {
this.integrate(dt);
if (this.pos.y < this.game.safeZoneHeight) {
this.saved = true;
this.alive = false;
}
}
}
Animar sprites desde una hoja
Dibujar círculos sirve para depurar, pero un juego necesita personajes animados. La técnica estándar es la hoja de sprites: una sola imagen que contiene todos los cuadros de animación ordenados en una cuadrícula. Cada fila suele representar una animación o una dirección, y cada columna un cuadro.
Para animar sprites desde una hoja usamos la versión de nueve argumentos de drawImage, que permite recortar una región de la imagen de origen y dibujarla en el lienzo.
// src/utils/sprite.js
export class SpriteAnimator {
constructor({ image, frameWidth, frameHeight, frames, fps = 12 }) {
this.image = image;
this.frameWidth = frameWidth;
this.frameHeight = frameHeight;
this.frames = frames;
this.interval = 1 / fps;
this.elapsed = 0;
this.frameX = 0;
this.frameY = 0; // fila: dirección o tipo de animación
}
update(dt) {
this.elapsed += dt;
if (this.elapsed >= this.interval) {
this.elapsed -= this.interval;
this.frameX = (this.frameX + 1) % this.frames;
}
}
draw(ctx, x, y, scale = 1) {
const w = this.frameWidth * scale;
const h = this.frameHeight * scale;
ctx.drawImage(
this.image,
this.frameX * this.frameWidth,
this.frameY * this.frameHeight,
this.frameWidth,
this.frameHeight,
x - w / 2,
y - h / 2,
w,
h
);
}
}
La animación tiene su propia frecuencia, independiente del bucle principal. Aunque el juego se dibuje a 144 fotogramas por segundo, el sprite solo cambia de cuadro doce veces por segundo, que es la cadencia típica de la animación 2D tradicional.
Un recurso muy vistoso es elegir la fila del sprite según el ángulo entre el personaje y el puntero, de forma que el jugador “mire” hacia donde se mueve. Con Math.atan2 obtenemos el ángulo y lo convertimos en una de ocho direcciones:
// src/entities/player.js (fragmento)
updateDirection() {
const dx = this.game.input.pointer.x - this.pos.x;
const dy = this.game.input.pointer.y - this.pos.y;
const angle = Math.atan2(dy, dx); // de -PI a PI
// Dividimos la circunferencia en 8 sectores de 45 grados
const sector = Math.round(angle / (Math.PI / 4));
this.sprite.frameY = (sector + 8) % 8;
// Si el puntero está muy cerca, mostramos la animación de reposo
if (Math.hypot(dx, dy) < this.radius) this.sprite.frameX = 0;
}
Las imágenes deben estar cargadas antes de comenzar la partida. Una función que espere a todas las promesas de carga evita que el primer fotograma aparezca vacío:
// src/utils/sprite.js (fragmento)
export function loadImage(src) {
const img = new Image();
img.src = src;
return img.decode().then(() => img);
}
export async function loadAssets(map) {
const entries = await Promise.all(
Object.entries(map).map(async ([key, src]) => [
key,
await loadImage(src),
])
);
return Object.fromEntries(entries);
}
El método decode() devuelve una promesa que se resuelve cuando la imagen está decodificada y lista para dibujarse sin bloqueos.
Ordenar el dibujado por profundidad
En un juego con vista cenital ligeramente inclinada, los objetos que están más abajo en la pantalla parecen estar más cerca del espectador. Si el jugador pasa por delante de una semilla, debe taparla; si pasa por detrás, la semilla debe taparlo a él.
La solución es ordenar todas las entidades por su coordenada vertical antes de dibujarlas:
// src/game.js (fragmento)
draw() {
const { ctx } = this;
ctx.clearRect(0, 0, this.width, this.height);
ctx.drawImage(this.assets.fondo, 0, 0, this.width, this.height);
const drawables = [this.player, ...this.seeds, ...this.beetles];
drawables
.sort((a, b) => a.pos.y - b.pos.y)
.forEach((entity) => entity.draw(ctx));
this.drawHud(ctx);
}
Este truco, conocido como ordenación por profundidad o “painter’s algorithm”, añade una sensación de volumen notable con una sola línea de código.
Una inteligencia artificial simple para los enemigos
Los escarabajos necesitan un comportamiento que resulte creíble sin ser imposible de vencer. Una máquina de estados finitos es la herramienta ideal: el enemigo siempre está en un estado concreto y cambia de estado cuando se cumplen ciertas condiciones.
Nuestros escarabajos tendrán tres estados:
- Deambular: se mueven hacia un punto aleatorio del jardín.
- Perseguir: si detectan una semilla cercana, se dirigen hacia ella.
- Aturdido: tras ser empujados con fuerza por el jugador, quedan inmóviles un momento.
// src/entities/beetle.js
import { Body } from "./body.js";
import { Vec2 } from "../vector.js";
const WANDER = "wander";
const CHASE = "chase";
const STUNNED = "stunned";
export class Beetle extends Body {
constructor(game, x, y) {
super({ x, y, radius: 30, mass: 2, friction: 0.1 });
this.game = game;
this.state = WANDER;
this.maxSpeed = 70;
this.visionRange = 220;
this.stunTime = 0;
this.target = this.randomPoint();
}
randomPoint() {
const { width, height, safeZoneHeight } = this.game;
return new Vec2(
Math.random() * width,
safeZoneHeight + Math.random() * (height - safeZoneHeight)
);
}
nearestSeed() {
let best = null;
let bestDist = this.visionRange;
for (const seed of this.game.seeds) {
const d = seed.pos.sub(this.pos).length();
if (d < bestDist) {
best = seed;
bestDist = d;
}
}
return best;
}
stun(seconds = 1.5) {
this.state = STUNNED;
this.stunTime = seconds;
}
steerTowards(point, dt) {
const desired = point.sub(this.pos).normalize().scale(this.maxSpeed);
// Giro suave: mezclamos la velocidad actual con la deseada
this.vel = this.vel.add(
desired.sub(this.vel).scale(Math.min(3 * dt, 1))
);
}
update(dt) {
switch (this.state) {
case STUNNED:
this.stunTime -= dt;
if (this.stunTime <= 0) this.state = WANDER;
break;
case CHASE: {
const seed = this.nearestSeed();
if (!seed) {
this.state = WANDER;
break;
}
this.steerTowards(seed.pos, dt);
break;
}
case WANDER:
default:
if (this.nearestSeed()) {
this.state = CHASE;
break;
}
if (this.target.sub(this.pos).length() < 20) {
this.target = this.randomPoint();
}
this.steerTowards(this.target, dt);
}
this.integrate(dt);
}
}
El método steerTowards implementa una versión simplificada de los comportamientos de dirección de Craig Reynolds. En lugar de girar instantáneamente, el escarabajo corrige su velocidad poco a poco hacia la dirección deseada, lo que produce curvas orgánicas en vez de movimientos robóticos.
Ajustar visionRange y maxSpeed es la forma más directa de controlar la dificultad. Un escarabajo que ve lejos pero se mueve despacio da tiempo al jugador para reaccionar; uno rápido con visión corta resulta impredecible. Podemos incrementar estos valores conforme avanza la partida para crear una curva de dificultad.
Para conectar la IA con la física, al resolver una colisión entre el jugador y un escarabajo comprobamos la fuerza del choque y, si supera un umbral, aturdimos al enemigo:
// src/game.js (fragmento)
for (const beetle of this.beetles) {
const speedBefore = beetle.vel.length();
if (resolveCollision(this.player, beetle, 0.8)) {
if (beetle.vel.length() - speedBefore > 150) beetle.stun();
}
}
Temporizadores y eventos en el tiempo
Muchas mecánicas dependen del tiempo: aparece una semilla nueva cada pocos segundos, los escarabajos se multiplican cada medio minuto o se muestra un mensaje durante tres segundos. Es tentador usar setTimeout o setInterval, pero en un juego eso es un error. Esos temporizadores siguen corriendo con la pestaña oculta o con el juego en pausa, y no respetan el paso de tiempo de nuestra simulación.
La alternativa es medir el tiempo dentro del propio bucle con una pequeña clase reutilizable:
// src/utils/timer.js
export class Timer {
constructor(interval, callback, { repeat = true } = {}) {
this.interval = interval;
this.callback = callback;
this.repeat = repeat;
this.elapsed = 0;
this.active = true;
}
update(dt) {
if (!this.active) return;
this.elapsed += dt;
while (this.elapsed >= this.interval && this.active) {
this.elapsed -= this.interval;
this.callback();
if (!this.repeat) this.active = false;
}
}
reset() {
this.elapsed = 0;
this.active = true;
}
}
Como el temporizador avanza con el mismo dt que la física, se detiene automáticamente cuando el juego se pausa y se mantiene sincronizado con todo lo demás. El bucle while garantiza que, si un paso de tiempo es muy largo, no se pierdan eventos.
La clase Game: puntuación, fin de partida y reinicio
Con todas las piezas listas, la clase Game las orquesta. Su responsabilidad es crear las entidades, actualizarlas en orden, aplicar las reglas y gestionar el estado de la partida.
// src/game.js
import { Input } from "./input.js";
import { Player } from "./entities/player.js";
import { Seed } from "./entities/seed.js";
import { Beetle } from "./entities/beetle.js";
import { Timer } from "./utils/timer.js";
export class Game {
constructor(canvas, ctx) {
this.canvas = canvas;
this.ctx = ctx;
this.width = 1280;
this.height = 720;
this.safeZoneHeight = 100;
this.targetScore = 20;
this.input = new Input(canvas, this);
this.debug = false;
this.reset();
}
reset() {
this.player = new Player(this);
this.seeds = [];
this.beetles = [];
this.score = 0;
this.lives = 5;
this.state = "playing";
for (let i = 0; i < 3; i++) this.spawnBeetle();
for (let i = 0; i < 5; i++) this.spawnSeed();
this.timers = [
new Timer(2.5, () => this.spawnSeed()),
new Timer(25, () => this.spawnBeetle()),
];
}
spawnSeed() {
if (this.seeds.length >= 10) return;
const x = 80 + Math.random() * (this.width - 160);
const y = this.height - 60 - Math.random() * 200;
this.seeds.push(new Seed(this, x, y));
}
spawnBeetle() {
const x = Math.random() < 0.5 ? 40 : this.width - 40;
const y = this.safeZoneHeight + 100 + Math.random() * 400;
this.beetles.push(new Beetle(this, x, y));
}
update(dt) {
if (this.state !== "playing") return;
this.timers.forEach((t) => t.update(dt));
this.player.update(dt);
this.seeds.forEach((s) => s.update(dt));
this.beetles.forEach((b) => b.update(dt));
this.handleCollisions();
// Reglas de puntuación
for (const seed of this.seeds) {
if (seed.saved) this.score++;
}
this.seeds = this.seeds.filter((s) => s.alive);
if (this.score >= this.targetScore) this.state = "won";
if (this.lives <= 0) this.state = "lost";
}
}
El método handleCollisions agrupa todas las interacciones: jugador contra semillas, jugador contra escarabajos, semillas entre sí y escarabajos contra semillas. En este último caso no hay rebote, sino destrucción:
// src/game.js (fragmento)
handleCollisions() {
for (const seed of this.seeds) resolveCollision(this.player, seed);
for (let i = 0; i < this.seeds.length; i++) {
for (let j = i + 1; j < this.seeds.length; j++) {
resolveCollision(this.seeds[i], this.seeds[j]);
}
}
for (const beetle of this.beetles) {
for (const seed of this.seeds) {
if (seed.alive && checkCollision(beetle, seed).colliding) {
seed.alive = false;
this.lives--;
}
}
}
[...this.seeds, ...this.beetles].forEach((b) => keepInBounds(b, this.width, this.height));
}
El reinicio consiste simplemente en volver a llamar a reset(), que reconstruye todo el estado desde cero. Concentrar la inicialización en un único método es una práctica que ahorra muchos errores: si el estado inicial se reparte entre el constructor y otros lugares, tarde o temprano el reinicio dejará algo a medias.
Por último, una interfaz mínima informa al jugador del progreso y muestra el mensaje final:
// src/game.js (fragmento)
drawHud(ctx) {
ctx.save();
ctx.fillStyle = 'rgba(255, 255, 255, 0.15)';
ctx.fillRect(0, 0, this.width, this.safeZoneHeight);
ctx.fillStyle = '#ffffff';
ctx.font = '28px system-ui';
ctx.textAlign = 'left';
ctx.fillText(`Semillas: ${this.score}/${this.targetScore}`, 24, 50);
ctx.fillText(`Vidas: ${this.lives}`, 24, 85);
if (this.state !== 'playing') {
ctx.fillStyle = 'rgba(0, 0, 0, 0.6)';
ctx.fillRect(0, 0, this.width, this.height);
ctx.fillStyle = '#ffffff';
ctx.textAlign = 'center';
ctx.font = '64px system-ui';
const message = this.state === 'won' ? '¡Jardín a salvo!' : 'Los escarabajos ganaron';
ctx.fillText(message, this.width / 2, this.height / 2);
ctx.font = '28px system-ui';
ctx.fillText('Pulsa R o el botón Reiniciar', this.width / 2, this.height / 2 + 60);
}
ctx.restore();
}
Usar ctx.save() y ctx.restore() alrededor del dibujado de la interfaz garantiza que los cambios de color, fuente o alineación no afecten al resto de elementos.
Un lienzo nítido en cualquier pantalla
La mayoría de los portátiles y teléfonos actuales tienen pantallas de alta densidad, donde un píxel CSS equivale a dos o tres píxeles físicos. Si el lienzo tiene una resolución interna de 1280 × 720 pero se muestra en una pantalla con devicePixelRatio igual a 2, se verá borroso.
La solución es aumentar la resolución interna del lienzo según la densidad y escalar el contexto para seguir trabajando en coordenadas lógicas:
// src/main.js (fragmento)
function setupCanvas(canvas, ctx, logicalWidth, logicalHeight) {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = logicalWidth * dpr;
canvas.height = logicalHeight * dpr;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
setupCanvas(canvas, ctx, 1280, 720);
Limitamos el factor a 2 porque, a partir de ahí, la mejora visual es mínima y el costo de dibujar el triple de píxeles se nota en dispositivos modestos. Gracias a setTransform, el resto del código sigue pensando en un mundo de 1280 × 720 y no necesita saber nada sobre la pantalla real.
Si usas arte pixelado, añade ctx.imageSmoothingEnabled = false para que los sprites conserven sus bordes nítidos al escalarse.
Rendimiento: cómo mantener el juego fluido
Un juego de esta escala funcionará con fluidez en prácticamente cualquier equipo, pero conviene conocer las técnicas que marcan la diferencia cuando el proyecto crece.
La primera es reducir el trabajo de dibujado. Dibujar el fondo completo en cada fotograma es costoso si se trata de una imagen grande con muchos detalles. Una alternativa es pintar el escenario estático en un lienzo separado, situado debajo con CSS, y actualizar solo la capa de las entidades.
<div class="stage">
<canvas id="fondo"></canvas>
<canvas id="juego"></canvas>
</div>
La segunda es evitar crear objetos innecesarios en el bucle. Nuestra clase Vec2 inmutable genera muchos vectores temporales; con cientos de entidades conviene reutilizarlos o trabajar con números sueltos en las partes más calientes del código.
La tercera es optimizar las colisiones con una partición espacial. Cuando hay cientos de cuerpos, comparar todos los pares deja de ser viable. Una cuadrícula espacial divide el mundo en celdas y solo compara cuerpos que comparten celda o celdas vecinas.
// Cuadrícula espacial básica
function buildGrid(bodies, cellSize) {
const grid = new Map();
for (const body of bodies) {
const key = `${Math.floor(body.pos.x / cellSize)},${Math.floor(body.pos.y / cellSize)}`;
if (!grid.has(key)) grid.set(key, []);
grid.get(key).push(body);
}
return grid;
}
Por último, mide antes de optimizar. La pestaña de rendimiento de las herramientas de desarrollo del navegador muestra cuánto tarda cada fotograma y qué funciones consumen más tiempo. Un fotograma debe completarse en menos de 16,6 milisegundos para mantener 60 FPS, y en menos de 8,3 milisegundos para 120 FPS.
Errores comunes y solución de problemas
Al desarrollar juegos con Canvas aparecen siempre los mismos problemas. Conocerlos de antemano ahorra horas de depuración.
El lienzo aparece en blanco y la consola muestra un error de módulos. Casi siempre se debe a abrir el archivo con file://. El mensaje típico es parecido a este:
Access to script at 'file:///.../src/main.js' from origin 'null' has been blocked by CORS policy
La solución es servir el proyecto con un servidor local, como vimos al principio.
El juego va más rápido en un monitor que en otro. Significa que alguna velocidad no se multiplica por dt. Revisa cada lugar donde se modifica una posición o una velocidad y asegúrate de que el tiempo delta interviene.
Los objetos atraviesan las paredes o entre sí. Es el efecto túnel: cuando un cuerpo se mueve muy rápido, en un paso está a un lado del obstáculo y en el siguiente al otro, sin haberse detectado nunca el contacto. Se soluciona limitando la velocidad máxima, reduciendo el paso de la física o dividiendo cada actualización en varios subpasos:
const SUBSTEPS = 4;
for (let i = 0; i < SUBSTEPS; i++) {
game.update(STEP / SUBSTEPS);
}
Los cuerpos vibran cuando están en reposo uno contra otro. Suele deberse a aplicar impulsos cuando los cuerpos ya se están separando o a una corrección de posición excesiva. La comprobación de velAlongNormal > 0 que incluimos en resolveCollision evita la mayor parte de estos casos.
El personaje no sigue al puntero con precisión. Si el lienzo se escala con CSS y no conviertes las coordenadas del evento, el personaje apuntará a un lugar desplazado. Revisa la función de conversión que usa getBoundingClientRect.
Las imágenes no aparecen en el primer fotograma. El juego empezó antes de que terminaran de cargarse. Espera a la promesa de loadAssets antes de arrancar el bucle.
Para depurar la física es muy útil un modo que dibuje los círculos de colisión encima de los sprites. Activarlo con una tecla permite comprobar de un vistazo si los radios coinciden con el arte:
window.addEventListener("keydown", (e) => {
if (e.key.toLowerCase() === "d") game.debug = !game.debug;
});
Cuándo usar una librería en lugar de JavaScript puro
Construir todo a mano es la mejor forma de aprender, pero no siempre la más eficiente para un proyecto comercial. Conviene conocer el ecosistema para decidir con criterio.
Phaser es un framework completo para juegos 2D en el navegador. Incluye escenas, cargador de recursos, cámaras, audio, sistemas de física y soporte para mapas de mosaicos. Es la opción adecuada si quieres publicar un juego completo sin reinventar la infraestructura.
PixiJS es un motor de renderizado 2D muy rápido basado en WebGL y WebGPU. No impone una estructura de juego ni trae física, pero dibuja miles de sprites con un rendimiento muy superior al de Canvas 2D. Encaja cuando el cuello de botella es el dibujado.
Matter.js y Planck.js son motores de física 2D. Resuelven cuerpos rígidos con formas complejas, articulaciones, rotación y pilas estables de objetos, algo que nuestra física de círculos no contempla. Si tu juego depende de física realista con polígonos, implementarla desde cero no compensa.
Para gráficos 3D, Three.js y Babylon.js son las referencias en el navegador.
El conocimiento adquirido en este tutorial sigue siendo valioso aunque acabes usando cualquiera de estas herramientas. El bucle de juego, el tiempo delta, las máquinas de estados y la respuesta a colisiones son conceptos universales que aparecen, con otros nombres, en todos los motores.
Ideas para seguir ampliando el juego
Una vez que la base funciona, el proyecto admite muchas mejoras que sirven como práctica:
- Añadir sonido con la Web Audio API para los choques, las semillas salvadas y la derrota.
- Guardar la mejor puntuación en
localStoragepara que persista entre sesiones. - Crear obstáculos estáticos, como rocas, que las semillas deban rodear.
- Incorporar partículas cuando una semilla llega a la zona segura.
- Añadir soporte para mando con la Gamepad API.
- Convertir el juego en una aplicación web progresiva para poder instalarlo y jugarlo sin conexión.
// Guardar la mejor puntuación de forma segura
function saveBestScore(score) {
try {
const best = Number(localStorage.getItem("mejorPuntuacion")) || 0;
if (score > best)
localStorage.setItem("mejorPuntuacion", String(score));
} catch {
// El almacenamiento puede no estar disponible en modo privado
}
}
Conclusiones
Construir un juego de física con JavaScript puro demuestra que el navegador es una plataforma de videojuegos completa. Con la API Canvas, Pointer Events y requestAnimationFrame tenemos todo lo necesario para dibujar, recibir la entrada del usuario y animar a la frecuencia de la pantalla, sin instalar una sola dependencia.
Los conceptos que hemos trabajado son la base de cualquier motor. El tiempo delta y el paso fijo hacen que el juego se comporte igual en todos los equipos. La detección de colisiones entre círculos y la respuesta basada en impulsos producen interacciones creíbles con muy poco código. Las hojas de sprites y el ordenado por profundidad aportan vida visual, y una máquina de estados sencilla basta para que los enemigos parezcan tener intenciones propias.
También hemos visto prácticas que separan un prototipo de un juego robusto: temporizadores integrados en el bucle en lugar de setTimeout, un único punto de reinicio del estado, un lienzo adaptado a pantallas de alta densidad y un modo de depuración para visualizar la física.
El siguiente paso natural es experimentar: cambiar masas y coeficientes de restitución, añadir nuevos estados a la inteligencia artificial o introducir obstáculos. Y cuando el proyecto lo pida, dar el salto a herramientas como Phaser, PixiJS o Matter.js, ahora entendiendo qué hacen por debajo.
