Skip to main content

Cómo Construir un Bullet Heaven

Este tutorial fue escrito para la v2 del motor.

Tres tutoriales anteriores sentaron las bases para este — object pooling para reciclar entidades sin recolección de basura, generación de oleadas para lanzar enemigos en ráfagas cada vez más intensas, y sistemas de cámara para seguir a un jugador a través de una arena grande. Ahora los conectamos todos en un juego completo: un bullet heaven, a veces llamado survivors-like.

El género se basa en una sola idea: nunca apuntas ni disparas manualmente. Tus armas atacan automáticamente, y tu único trabajo es moverte. Los enemigos llegan desde todas las direcciones, recoges XP de sus restos, subes de nivel, eliges mejoras e intentas sobrevivir el mayor tiempo posible. Es una combinación perfecta para el motor porque todo el juego funciona con tres object pools y un objeto de estadísticas.

Lo construiremos en siete pasos, empezando con una arena vacía y terminando con un juego jugable que tiene múltiples tipos de armas, variedad de enemigos y un sistema de mejoras.

La Arena

Todo survivors-like necesita un espacio más grande que la pantalla. El nuestro es un mundo de 384×384 visto a través del canvas de 128×128 del motor. El jugador es un círculo blanco, y la cámara lo sigue con un lerp suave — el mismo pipeline del tutorial de sistemas de cámara:

engine.scope(
    ({ start, cls, btn, circfill, rectfill, rect, caption, camera, creset, rnd }) => {
        const WORLD = 384;
        const CANVAS = 128;
        const SPEED = 1.5;
        const LERP = 0.08;

        let px = WORLD / 2;
        let py = WORLD / 2;
        let camX = px - CANVAS / 2;
        let camY = py - CANVAS / 2;

        const landmarks = [];

        function init() {
            for (let i = 0; i < 12; i++) {
                landmarks.push({
                    x: Math.floor(rnd(WORLD - 40) + 12),
                    y: Math.floor(rnd(WORLD - 40) + 12),
                    w: Math.floor(rnd(16) + 8),
                    h: Math.floor(rnd(16) + 8),
                });
            }
        }

        function update() {
            if (btn('ArrowLeft') || btn('a')) px -= SPEED;
            if (btn('ArrowRight') || btn('d')) px += SPEED;
            if (btn('ArrowUp') || btn('w')) py -= SPEED;
            if (btn('ArrowDown') || btn('s')) py += SPEED;

            px = Math.max(3, Math.min(WORLD - 3, px));
            py = Math.max(3, Math.min(WORLD - 3, py));

            const targetX = px - CANVAS / 2;
            const targetY = py - CANVAS / 2;
            camX += (targetX - camX) * LERP;
            camY += (targetY - camY) * LERP;
            camX = Math.max(0, Math.min(WORLD - CANVAS, camX));
            camY = Math.max(0, Math.min(WORLD - CANVAS, camY));
        }

        function draw() {
            cls(0);
            camera(camX, camY);

            rect(0, 0, WORLD - 1, WORLD - 1, 6);

            for (const lm of landmarks) {
                rectfill(lm.x, lm.y, lm.x + lm.w, lm.y + lm.h, 1);
            }

            circfill(px, py, 3, 7);

            creset();
            caption('x:' + Math.floor(px) + ' y:' + Math.floor(py), 1, 1, 7);
        }

        start({ sprites: {}, sounds: {}, init, update, draw, target });
    },
);

Los rectángulos azul oscuro esparcidos por la arena son puntos de referencia — te dan sensación de movimiento al desplazarte. Sin ellos, el fondo negro hace difícil saber si la cámara está funcionando. El rect gris marca el borde del mundo, y el jugador está limitado dentro de él.

Después de que camera() desplaza el origen de dibujo para el mundo, creset() lo restablece para que el HUD se dibuje en posiciones fijas de pantalla. Estamos usando caption() del plugin Caption aquí — dibuja texto con un fondo oscuro para que sea legible sobre cualquier escena.

Bullet Heaven: The Arena
Flechas o WASD para moverte por la arena

Oleadas de Enemigos

Una arena vacía no es mucho juego. Necesitamos enemigos — muchos, apareciendo fuera de pantalla y persiguiendo al jugador. Es el mismo patrón de object pooling del tutorial anterior: pre-asignar un array de objetos enemigos, marcarlos como inactivos y tomar uno cuando necesitemos generar:

const ENEMY_CAP = 100;

const enemies = [];
for (let i = 0; i < ENEMY_CAP; i++) {
    enemies.push({ x: 0, y: 0, speed: 0, color: 8, active: false });
}

function acquireEnemy() {
    for (const e of enemies) {
        if (!e.active) {
            e.active = true;
            return e;
        }
    }
    return null;
}

La generación usa la misma técnica de aparición en los bordes de generación de oleadas. Elige un lado aleatorio del viewport, coloca al enemigo justo fuera de pantalla en ese borde y déjalo caminar hacia el jugador. Cada oleada tiene un presupuesto — la oleada 1 genera 6 enemigos, la oleada 2 genera 8, y así sucesivamente. Entre oleadas hay una pausa corta para que el jugador tenga un momento para respirar:

let wave = 0;
let spawnBudget = 0;
let spawnTimer = 0;
let betweenWaves = true;
let betweenTimer = 0;
const WAVE_PAUSE = 120;
const SPAWN_INTERVAL = 15;
const WAVE_COLORS = [8, 2, 9, 14, 15, 4];

function startWave() {
    wave++;
    spawnBudget = 4 + wave * 2;
    spawnTimer = 0;
    betweenWaves = false;
}

function spawnEnemy() {
    const e = acquireEnemy();
    if (!e) return;

    const side = Math.floor(rnd(4));
    if (side === 0) { e.x = camX - 8; e.y = rnd(WORLD); }
    else if (side === 1) { e.x = camX + CANVAS + 8; e.y = rnd(WORLD); }
    else if (side === 2) { e.x = rnd(WORLD); e.y = camY - 8; }
    else { e.x = rnd(WORLD); e.y = camY + CANVAS + 8; }

    e.speed = 0.4 + wave * 0.05;
    e.color = WAVE_COLORS[(wave - 1) % WAVE_COLORS.length];
}

El movimiento de los enemigos es directo — cada frame, caminar hacia el jugador. El array WAVE_COLORS cicla entre colores de la paleta para que puedas notar cuándo empieza una nueva oleada — los enemigos rojos reemplazan a los rojo oscuro, luego el naranja reemplaza al rojo, y así sucesivamente:

for (const e of enemies) {
    if (!e.active) continue;
    const dx = px - e.x;
    const dy = py - e.y;
    const dist = Math.sqrt(dx * dx + dy * dy);
    if (dist > 0) {
        e.x += (dx / dist) * e.speed;
        e.y += (dy / dist) * e.speed;
    }
}

El sistema de oleadas se ejecuta en update(). Durante la pausa entre oleadas, un temporizador cuenta hacia arriba. Cuando llega a WAVE_PAUSE, comienza la siguiente oleada. Durante una oleada, los enemigos aparecen uno a la vez con un cooldown de SPAWN_INTERVAL hasta que se agota el presupuesto. Cuando todos los enemigos de la oleada mueren, la pausa comienza de nuevo.

Bullet Heaven: Enemy Waves
Los enemigos ahora aparecen desde los bordes y te persiguen en oleadas

Disparo Automático

La mecánica definitoria de un bullet heaven: el jugador nunca presiona un botón de disparo. Las armas disparan automáticamente, y tu única entrada es el movimiento. Para que esto funcione necesitamos un segundo object pool — esta vez para proyectiles — y una función de targeting que encuentre al enemigo más cercano:

const PROJ_CAP = 150;
const FIRE_COOLDOWN = 20;
const PROJ_SPEED = 3;
const PROJ_DAMAGE = 1;

const projectiles = [];
for (let i = 0; i < PROJ_CAP; i++) {
    projectiles.push({ x: 0, y: 0, dx: 0, dy: 0, active: false });
}

function acquireProjectile() {
    for (const p of projectiles) {
        if (!p.active) { p.active = true; return p; }
    }
    return null;
}

Cada FIRE_COOLDOWN frames, escaneamos el pool de enemigos buscando el enemigo activo más cercano y disparamos un proyectil hacia él. El proyectil almacena su velocidad como dx/dy para que vuele en línea recta a PROJ_SPEED:

function findNearest() {
    let best = null;
    let bestDist = Infinity;
    for (const e of enemies) {
        if (!e.active) continue;
        const dx = e.x - px;
        const dy = e.y - py;
        const d = dx * dx + dy * dy;
        if (d < bestDist) { bestDist = d; best = e; }
    }
    return best;
}

function fireAt(target) {
    const p = acquireProjectile();
    if (!p) return;
    p.x = px;
    p.y = py;
    const dx = target.x - px;
    const dy = target.y - py;
    const dist = Math.sqrt(dx * dx + dy * dy);
    p.dx = (dx / dist) * PROJ_SPEED;
    p.dy = (dy / dist) * PROJ_SPEED;
}

Nota que findNearest compara distancias al cuadrado para evitar sqrt — solo necesitamos el orden relativo, no la distancia real. fireAt sí necesita sqrt una vez para normalizar el vector de dirección.

La detección de colisiones verifica cada proyectil activo contra cada enemigo activo. Estamos usando distancia al cuadrado de nuevo: si el proyectil está dentro de 4 píxeles del centro de un enemigo (distancia² < 16), es un impacto. Al contacto, el proyectil se desactiva, el enemigo pierde HP, y si el HP llega a cero el enemigo muere:

for (const p of projectiles) {
    if (!p.active) continue;
    for (const e of enemies) {
        if (!e.active) continue;
        const dx = p.x - e.x;
        const dy = p.y - e.y;
        if (dx * dx + dy * dy < 16) {
            p.active = false;
            e.hp -= PROJ_DAMAGE;
            if (e.hp <= 0) {
                e.active = false;
                kills++;
            }
            break;
        }
    }
}

Los enemigos ahora tienen un campo hp que escala con las oleadas — 1 + Math.floor(wave / 3) — así que las oleadas posteriores necesitan más impactos para morir. El break después de un impacto importa: cada proyectil solo puede golpear a un enemigo por frame.

Bullet Heaven: Auto-Fire
Los proyectiles amarillos disparan automáticamente al enemigo más cercano

Sobrevivir

Ahora mismo los enemigos atraviesan al jugador sin consecuencia. Necesitamos daño por contacto, una barra de vida, retroceso, sacudida de cámara y un estado de muerte. El tutorial de sistemas de cámara cubrió la sacudida de cámara — aquí aplicamos la misma técnica al recibir un golpe.

Cuando un enemigo se superpone al jugador (distancia al cuadrado < CONTACT_RANGE), pasan tres cosas: el HP baja en 1, el jugador es empujado hacia atrás lejos del enemigo, y la cámara empieza a sacudirse. Un contador de frames de invencibilidad (iFrames) evita recibir múltiples golpes de la misma multitud en el mismo instante:

const MAX_HP = 5;
const IFRAMES = 60;
const KNOCKBACK = 8;
const SHAKE_DECAY = 0.85;
const SHAKE_STRENGTH = 4;
const CONTACT_RANGE = 36;

let hp = MAX_HP;
let iFrames = 0;
let shakeAmount = 0;
let state = 'playing';

La verificación de daño por contacto se ejecuta cada frame, pero solo cuando iFrames es cero. Al recibir un golpe, empujamos al jugador en la dirección opuesta al enemigo, lo limitamos dentro de la arena e iniciamos un cooldown de 60 frames:

if (iFrames <= 0) {
    for (const e of enemies) {
        if (!e.active) continue;
        const dx = px - e.x;
        const dy = py - e.y;
        if (dx * dx + dy * dy < CONTACT_RANGE) {
            hp--;
            iFrames = IFRAMES;
            shakeAmount = SHAKE_STRENGTH;
            const dist = Math.sqrt(dx * dx + dy * dy);
            if (dist > 0) {
                px += (dx / dist) * KNOCKBACK;
                py += (dy / dist) * KNOCKBACK;
                px = Math.max(3, Math.min(WORLD - 3, px));
                py = Math.max(3, Math.min(WORLD - 3, py));
            }
            if (hp <= 0) state = 'dead';
            break;
        }
    }
}

La sacudida de cámara añade un desplazamiento aleatorio a la cámara cada frame, decayendo con el tiempo. El jugador parpadea durante la invencibilidad — omitimos el dibujo en frames alternos con frame % 4 < 2:

let sx = 0, sy = 0;
if (shakeAmount > 0.5) {
    sx = (rnd(2) - 1) * shakeAmount;
    sy = (rnd(2) - 1) * shakeAmount;
}
camera(camX + sx, camY + sy);

La barra de HP son dos llamadas rectfill superpuestas en la parte inferior de la pantalla: un fondo oscuro y un relleno de color que se reduce al bajar el HP. El color del relleno cambia con la salud — verde por encima del 60%, amarillo por encima del 30%, rojo por debajo:

rectfill(1, CANVAS - 7, 41, CANVAS - 3, 5);
const fillW = Math.floor((hp / MAX_HP) * 40);
const hpRatio = hp / MAX_HP;
const hpColor = hpRatio > 0.6 ? 11 : hpRatio > 0.3 ? 10 : 8;
if (fillW > 0) rectfill(1, CANVAS - 7, 1 + fillW, CANVAS - 3, hpColor);
caption('hp', 44, CANVAS - 8, 7);

Cuando el HP llega a cero, el estado del juego cambia a 'dead' y update() deja de procesar. Un overlay simple muestra la puntuación final.

Bullet Heaven: Staying Alive
Los enemigos causan daño por contacto con retroceso y sacudida de cámara

XP y Subida de Nivel

Matar enemigos debería sentirse gratificante. En el género survivors, cada enemigo suelta una gema de XP, y recolectar suficientes gemas te sube de nivel. Necesitamos un tercer object pool para las gemas, un sistema de recogida magnética y una barra de XP:

const GEM_CAP = 100;
const MAGNET_RANGE = 20;
const COLLECT_RANGE = 6;
const XP_PER_LEVEL = 5;

let xp = 0;
let level = 1;
let xpNeeded = XP_PER_LEVEL;

const gems = [];
for (let i = 0; i < GEM_CAP; i++) {
    gems.push({ x: 0, y: 0, active: false });
}

function acquireGem() {
    for (const g of gems) {
        if (!g.active) { g.active = true; return g; }
    }
    return null;
}

Cuando un enemigo muere, generamos una gema cerca de su posición con un pequeño desplazamiento aleatorio para que las gemas de la misma muerte no se apilen perfectamente:

function killEnemy(e) {
    e.active = false;
    kills++;
    const g = acquireGem();
    if (g) {
        g.x = e.x + rnd(6) - 3;
        g.y = e.y + rnd(6) - 3;
    }
}

La recolección de gemas usa dos verificaciones de distancia. Si el jugador está dentro de COLLECT_RANGE, la gema se recoge instantáneamente. Si el jugador está más lejos pero dentro de MAGNET_RANGE, la gema se desliza hacia él a una velocidad fija. Este enfoque de dos fases les da a las gemas un efecto de atracción satisfactorio — empiezan a deslizarse y luego se enganchan al acercarse lo suficiente:

for (const g of gems) {
    if (!g.active) continue;
    const dx = px - g.x;
    const dy = py - g.y;
    const distSq = dx * dx + dy * dy;
    if (distSq < COLLECT_RANGE * COLLECT_RANGE) {
        g.active = false;
        xp++;
        if (xp >= xpNeeded) {
            xp = 0;
            level++;
            xpNeeded = XP_PER_LEVEL + level * 2;
        }
    } else if (distSq < MAGNET_RANGE * MAGNET_RANGE) {
        const dist = Math.sqrt(distSq);
        g.x += (dx / dist) * 1.5;
        g.y += (dy / dist) * 1.5;
    }
}

La barra de XP funciona igual que la barra de HP — un fondo rectfill con un relleno verde que crece al acumular XP. El umbral para subir de nivel aumenta cada nivel (XP_PER_LEVEL + level * 2), así que los niveles posteriores necesitan más eliminaciones para alcanzarse.

Bullet Heaven: XP and Leveling Up
Las gemas de XP verdes caen de los enemigos y se atraen magnéticamente hacia ti

Opciones de Mejora

Subir de nivel sin recompensa no tiene sentido. Cuando la barra de XP se llena, el juego debería pausarse, presentar tres mejoras aleatorias y dejar que el jugador elija una. Aquí es donde el objeto plano stats da frutos — cada mejora es una función que modifica un número:

const stats = {
    speed: 1.5,
    fireRate: 20,
    damage: 1,
    bulletCount: 1,
    magnetRange: MAGNET_RANGE,
};

const UPGRADES = [
    { name: 'fire rate', apply: () => { stats.fireRate = Math.max(5, stats.fireRate - 3); } },
    { name: 'damage', apply: () => { stats.damage++; } },
    { name: 'multi-shot', apply: () => { stats.bulletCount++; } },
    { name: 'move speed', apply: () => { stats.speed += 0.3; } },
    { name: 'magnet range', apply: () => { stats.magnetRange += 10; } },
];

Cuando el jugador sube de nivel, mezclamos el array de mejoras y tomamos las tres primeras. El estado del juego cambia a 'levelup', que congela toda la lógica del juego — update() retorna temprano después de procesar solo la entrada del menú:

function levelUp() {
    level++;
    xp = 0;
    xpNeeded = XP_PER_LEVEL + level * 2;
    state = 'levelup';
    menuCursor = 0;

    const shuffled = [...UPGRADES].sort(() => rnd(1) - 0.5);
    menuChoices = shuffled.slice(0, 3);
}

La navegación del menú usa btnp() en lugar de btn() — se dispara una vez por pulsación en lugar de cada frame, así que el cursor no vuela entre las opciones. Arriba/Abajo mueve el cursor, Z o Espacio confirma:

if (state === 'levelup') {
    if (btnp('ArrowUp') || btnp('w')) {
        menuCursor = (menuCursor - 1 + menuChoices.length) % menuChoices.length;
    }
    if (btnp('ArrowDown') || btnp('s')) {
        menuCursor = (menuCursor + 1) % menuChoices.length;
    }
    if (btnp('z') || btnp(' ')) {
        menuChoices[menuCursor].apply();
        state = 'playing';
    }
    return;
}

La mejora de multi-shot cambia cómo funciona fireAt. En lugar de disparar un solo proyectil, despliega bulletCount proyectiles en un arco centrado en la dirección de apuntado. Cada uno está desplazado 15 grados:

function fireAt(t) {
    const dx = t.x - px;
    const dy = t.y - py;
    const baseAngle = Math.atan2(dy, dx);
    const spread = 15 * (Math.PI / 180);
    const count = stats.bulletCount;
    const startAngle = baseAngle - spread * (count - 1) / 2;

    for (let i = 0; i < count; i++) {
        const p = acquireProjectile();
        if (!p) return;
        p.x = px;
        p.y = py;
        const angle = startAngle + spread * i;
        p.dx = Math.cos(angle) * PROJ_SPEED;
        p.dy = Math.sin(angle) * PROJ_SPEED;
    }
}

Todas las demás estadísticas — speed, fireRate, damage, magnetRange — ya están conectadas a la lógica del juego a través del objeto stats. Elegir "fire rate" hace el cooldown más corto, elegir "damage" hace que cada impacto quite más HP, y así sucesivamente. No se necesita manejo especial.

Bullet Heaven: Upgrade Choices
Sube de nivel para elegir mejoras como multi-shot y rango de imán

El Juego Completo

Las secciones anteriores construyeron cada sistema por separado. Ahora los conectamos y añadimos algunas cosas que hacen que el juego se sienta terminado: variedad de enemigos, orbes de escudo, números de daño flotantes, anuncios de oleada y un ciclo de reinicio.

Tipos de Enemigos

Un solo tipo de enemigo se vuelve repetitivo rápido. La versión completa genera un tipo aleatorio para cada aparición basándose en la oleada actual. Los enemigos normales se comportan exactamente como antes. Los enemigos rápidos aparecen desde la oleada 3 — son más pequeños, naranjas y se mueven aproximadamente al doble de velocidad, pero mueren de un golpe. Los enemigos tanque aparecen desde la oleada 5 — son más grandes, gris oscuro, se mueven lento y necesitan varios golpes para morir. Los enemigos tanque también tienen una pequeña barra de HP sobre su cabeza para que el jugador pueda ver cuánta salud les queda:

function spawnEnemy() {
    const e = acquireEnemy();
    if (!e) return;

    // ... edge spawning as before ...

    const roll = rnd(1);
    if (wave >= 5 && roll < 0.15) {
        e.type = 'tanky';
        e.speed = 0.25 + wave * 0.02;
        e.color = 5;
        e.hp = 3 + Math.floor(wave / 2);
        e.maxHp = e.hp;
    } else if (wave >= 3 && roll < 0.4) {
        e.type = 'fast';
        e.speed = 0.8 + wave * 0.08;
        e.color = 9;
        e.hp = 1;
        e.maxHp = 1;
    } else {
        e.type = 'normal';
        e.speed = 0.4 + wave * 0.05;
        e.color = 8;
        e.hp = 1 + Math.floor(wave / 3);
        e.maxHp = e.hp;
    }
}

El dibujo varía según el tipo también. Los enemigos tanque tienen un radio de 4, los rápidos de 2, y los normales se quedan en 3. La barra de HP del tanque son dos pequeñas llamadas rectfill — un fondo oscuro y un relleno rojo proporcional a la salud restante.

Orbes de Escudo

La lista de mejoras gana una sexta opción: orbes de escudo. Cada orbe orbita al jugador a un radio fijo y daña a cualquier enemigo que toque. El ángulo de órbita se incrementa cada frame, y los orbes se espacian uniformemente alrededor del círculo con (Math.PI * 2 / count) * i:

const SHIELD_RADIUS = 16;
const SHIELD_DAMAGE = 2;
const SHIELD_HIT_RANGE = 25;

// in update:
if (stats.shieldCount > 0) {
    shieldAngle += 0.05;
    for (let i = 0; i < stats.shieldCount; i++) {
        const angle = shieldAngle + (Math.PI * 2 / stats.shieldCount) * i;
        const sx = px + Math.cos(angle) * SHIELD_RADIUS;
        const sy = py + Math.sin(angle) * SHIELD_RADIUS;
        for (const e of enemies) {
            if (!e.active) continue;
            const dx = sx - e.x;
            const dy = sy - e.y;
            if (dx * dx + dy * dy < SHIELD_HIT_RANGE) {
                damageEnemy(e, SHIELD_DAMAGE);
            }
        }
    }
}

Los orbes de escudo se dibujan como círculos azul claro. Elegir la mejora varias veces añade más orbes que se redistribuyen automáticamente alrededor del anillo.

Números de Daño

Cuando un enemigo recibe daño, un pequeño número flotante sube desde el impacto y se desvanece. Cada evento de daño añade un objeto { x, y, amount, life } al array dmgNumbers. Cada frame, cada número sube y pierde un punto de life. Cuando life llega a cero, se elimina:

const DMG_FLOAT_LIFE = 30;
const dmgNumbers = [];

function spawnDmgNumber(x, y, amount) {
    dmgNumbers.push({ x, y, amount, life: DMG_FLOAT_LIFE });
}

function damageEnemy(e, amount) {
    e.hp -= amount;
    spawnDmgNumber(e.x, e.y - 6, amount);
    if (e.hp <= 0) killEnemy(e);
}

Esto reemplaza la lógica de daño inline anterior. Ahora toda fuente de daño — proyectiles, orbes de escudo — llama a damageEnemy(), y el número flotante aparece automáticamente.

Anuncios de Oleada

Cada nueva oleada muestra brevemente su nombre en el centro de la pantalla usando caption(). Un waveAnnounceTimer cuenta regresivamente desde 90 frames (aproximadamente 1.5 segundos) y el texto desaparece cuando llega a cero.

Reinicio

La función resetGame() restablece cada pieza del estado — posición del jugador, cámara, eliminaciones, HP, XP, nivel, estadísticas, contadores de oleada — y desactiva todas las entidades en cada pool. Cuando el jugador muere, una pantalla de muerte de cuatro líneas muestra la oleada alcanzada, eliminaciones, nivel y un mensaje para reiniciar. Presionar Z o Espacio llama a resetGame() y el ciclo comienza de nuevo:

if (state === 'dead') {
    if (btnp('z') || btnp(' ')) resetGame();
    return;
}

La pantalla de muerte usa text() en lugar de caption() para que se superponga correctamente sobre el recuadro oscuro.

Bullet Heaven: Complete Example
El bullet heaven completo con tipos de enemigos, orbes de escudo y números de daño

Para Ir Más Allá

Tenemos un bullet heaven funcionando en menos de 400 líneas. La arquitectura — tres object pools, un objeto plano de estadísticas y una máquina de estados para menús — escala bien si quieres seguir construyendo.

Algunas ideas para probar:

  • Oleadas de jefes. Cada 5 oleadas, genera un solo enemigo con un gran pool de HP y un sprite más grande. Dale una barra de salud que ocupe toda la pantalla y omite la lógica de generación normal para esa oleada.
  • Más tipos de armas. Añade una mejora de rayo que encadene entre enemigos cercanos, o un AoE de suelo que dañe todo en un radio. Cada arma es otra estadística, otra sección en update() y otra llamada de dibujo.
  • Objetos pasivos. No toda recompensa necesita ser una opción del menú de mejoras. Genera objetos raros en el mundo — un corazón que restaura HP, un imán que atrae todas las gemas instantáneamente, un reloj que congela a los enemigos por unos segundos.
  • Un temporizador. La mayoría de los survivors-like tienen un límite de tiempo fijo — sobrevive 15 o 20 minutos y ganas. Añade un contador de frames, muestra minutos y segundos en el HUD y activa un estado de victoria cuando el tiempo se agote.
  • Progresión persistente. Guarda la oleada más alta alcanzada en una cookie o localStorage y muéstrala en la pantalla de muerte. Usa los patrones del tutorial Distribuyendo Tus Juegos para datos guardados.