Skip to main content

Cómo Construir un Sistema de Oleadas y Aparición

Este tutorial fue escrito en febrero de 2026, para la v2 del motor.

La mayoría de los juegos de acción necesitan una forma de lanzar enemigos al jugador. No todos a la vez — eso es caos — sino con un ritmo. Unos pocos aparecen, el jugador se encarga de ellos, y luego llega la siguiente tanda un poco más difícil. Eso es un sistema de oleadas, y es uno de los patrones más reutilizables en el desarrollo de juegos.

Vamos a construir uno desde cero. Sin sprites, sin personaje jugable, sin combate — solo un punto objetivo en el centro de la pantalla y enemigos apareciendo desde los bordes para converger en él. Al final tendrás un sistema de oleadas funcional con escalado de dificultad que puedes usar en cualquier juego.

Aparición desde los Bordes

Primero necesitamos enemigos, y los necesitamos viniendo desde fuera de la pantalla. El enfoque: pre-asignar un pool de objetos enemigos (el mismo patrón del tutorial de object pooling), elegir un borde aleatorio, colocar un enemigo a lo largo de él y apuntarlo hacia el centro.

Aquí está la configuración del pool. Cada enemigo tiene una posición, una velocidad y una bandera active:

const MAX_ENEMIES = 100;
const TARGET_X = 64;
const TARGET_Y = 64;
const SPEED = 0.5;

let pool = [];
for (let i = 0; i < MAX_ENEMIES; i++) {
    pool.push({ active: false, x: 0, y: 0, vx: 0, vy: 0 });
}

let activeCount = 0;

function acquire() {
    for (let i = 0; i < pool.length; i++) {
        if (!pool[i].active) {
            pool[i].active = true;
            activeCount++;
            return pool[i];
        }
    }
    return null;
}

function release(e) {
    e.active = false;
    activeCount--;
}

acquire busca un slot inactivo y lo devuelve. release lo marca como inactivo de nuevo. Sin asignación de memoria, sin basura — solo reciclaje.

La lógica de aparición elige uno de cuatro bordes y coloca al enemigo en un punto aleatorio a lo largo de él. Luego calcula una dirección hacia el objetivo y la multiplica por la velocidad:

function spawnEnemy() {
    let e = acquire();
    if (!e) return;

    let edge = randomIntegerBetween(0, 3);
    if (edge === 0) { e.x = rnd(128); e.y = 0; }
    else if (edge === 1) { e.x = 128; e.y = rnd(128); }
    else if (edge === 2) { e.x = rnd(128); e.y = 128; }
    else { e.x = 0; e.y = rnd(128); }

    let dx = TARGET_X - e.x;
    let dy = TARGET_Y - e.y;
    let dist = Math.sqrt(dx * dx + dy * dy);
    e.vx = (dx / dist) * SPEED;
    e.vy = (dy / dist) * SPEED;
}

El borde 0 es la parte superior, 1 es la derecha, 2 es la inferior, 3 es la izquierda. rnd(128) elige un punto aleatorio a lo largo del borde seleccionado. La matemática de dirección es estándar — restar posiciones para obtener un vector, dividir por su longitud para normalizar, multiplicar por velocidad.

Para el bucle de actualización, generamos enemigos con un temporizador y movemos cada enemigo activo. Cuando uno se acerca lo suficiente al objetivo, lo devolvemos al pool:

function update(time, frame) {
    if (frame % 20 === 0) spawnEnemy();

    for (let i = 0; i < pool.length; i++) {
        if (!pool[i].active) continue;
        pool[i].x += pool[i].vx;
        pool[i].y += pool[i].vy;

        let dx = TARGET_X - pool[i].x;
        let dy = TARGET_Y - pool[i].y;
        if (dx * dx + dy * dy < 16) release(pool[i]);
    }
}

frame % 20 === 0 genera un enemigo cada 20 frames — unos tres por segundo a 60 FPS. La verificación de despawn usa distancia al cuadrado (dx * dx + dy * dy < 16 es lo mismo que "distancia < 4") para no necesitar una raíz cuadrada en cada frame.

Spawning & Waves: Edge Spawning
Observa enemigos aparecer desde los bordes y converger en el centro

Círculos rojos aparecen desde bordes aleatorios, se desplazan hacia el objetivo blanco y desaparecen al llegar. El pool se encarga de todo — nada se crea ni se destruye después de la configuración inicial.

Organizando en Oleadas

Un flujo constante funciona para pruebas, pero los juegos necesitan ritmo. Generar una ráfaga, dejar que el jugador respire, y luego golpear de nuevo. Eso es lo que nos dan las oleadas — un presupuesto de enemigos a generar, una pausa cuando todos se han ido, y luego la siguiente oleada comienza.

Necesitamos algunas variables de estado para rastrear dónde estamos en el ciclo:

const BUDGET = 8;
const SPAWN_INTERVAL = 30;
const PAUSE_DURATION = 120;

let waveNumber = 1;
let enemiesToSpawn = BUDGET;
let spawnTimer = 0;
let pauseTimer = PAUSE_DURATION;
let pausing = true;

BUDGET es cuántos enemigos generará esta oleada. SPAWN_INTERVAL es el intervalo entre apariciones en frames — 30 frames significa dos por segundo. PAUSE_DURATION es cuánto esperar entre oleadas (120 frames = 2 segundos). Empezamos en pausa para que el jugador vea "WAVE 1" antes de que pase nada.

El bucle de actualización ahora tiene dos modos. Durante una pausa, contamos hacia atrás y esperamos. Durante una oleada, generamos con un temporizador y decrementamos el presupuesto:

function update() {
    if (pausing) {
        pauseTimer--;
        if (pauseTimer <= 0) {
            pausing = false;
            spawnTimer = 0;
        }
        return;
    }

    if (enemiesToSpawn > 0) {
        spawnTimer--;
        if (spawnTimer <= 0) {
            spawnEnemy();
            enemiesToSpawn--;
            spawnTimer = SPAWN_INTERVAL;
        }
    }

    for (let i = 0; i < pool.length; i++) {
        if (!pool[i].active) continue;
        pool[i].x += pool[i].vx;
        pool[i].y += pool[i].vy;

        let dx = TARGET_X - pool[i].x;
        let dy = TARGET_Y - pool[i].y;
        if (dx * dx + dy * dy < 16) release(pool[i]);
    }

    if (enemiesToSpawn <= 0 && activeCount === 0) {
        waveNumber++;
        enemiesToSpawn = BUDGET;
        pauseTimer = PAUSE_DURATION;
        pausing = true;
    }
}

La condición de fin de oleada importa: el presupuesto tiene que estar agotado Y todos los enemigos activos tienen que haberse ido. Así el último enemigo termina su recorrido antes de que empiece la pausa. Si solo verificáramos el presupuesto, la pausa comenzaría mientras los enemigos aún están en pantalla.

Spawning & Waves: Wave System
Los enemigos ahora llegan en oleadas con pausas entre ellas

Ahora puedes ver el ritmo. Ocho enemigos aparecen gradualmente, la pantalla se limpia, aparece "WAVE 2" y comienza la siguiente tanda. La misma lógica de aparición, pero organizada en ráfagas con espacio para respirar.

Escalando la Dificultad

Las oleadas que nunca cambian se vuelven aburridas rápido. Lo solucionamos escalando tres cosas a medida que sube el número de oleada: cuántos enemigos aparecen, qué tan rápido aparecen y qué tan rápido se mueven.

Vamos a envolver la configuración de oleada en una función que recalcula basándose en waveNumber:

function configureWave() {
    enemiesToSpawn = 3 + waveNumber * 2;
    spawnInterval = Math.max(10, 60 - waveNumber * 5);
    speed = 0.3 + waveNumber * 0.05;
}

Tres fórmulas, tres ejes de dificultad:

Más enemigos. 3 + waveNumber * 2 significa que la oleada 1 tiene 5, la oleada 5 tiene 13, la oleada 10 tiene 23. Crecimiento lineal — cada oleada agrega exactamente dos más que la anterior.

Aparición más rápida. Math.max(10, 60 - waveNumber * 5) empieza en 55 frames entre apariciones y se reduce en 5 cada oleada. Math.max lo limita para que el intervalo nunca baje de 10 — sin ese piso eventualmente llegaría a cero y generaría uno por frame.

Enemigos más rápidos. 0.3 + waveNumber * 0.05 empieza lento y agrega un pequeño incremento cada oleada. Para la oleada 10 se mueven a 0.8 píxeles por frame — notablemente más rápido pero no injusto.

Llamamos a configureWave() una vez al inicio y de nuevo cada vez que comienza una nueva oleada:

if (enemiesToSpawn <= 0 && activeCount === 0) {
    waveNumber++;
    configureWave();
    pauseTimer = PAUSE_DURATION;
    pausing = true;
}

Para feedback visual, cambiamos los colores de los enemigos por oleada. Un arreglo de fríos a cálidos — azules al inicio, rojos después — hace obvio que las cosas están escalando:

const WAVE_COLORS = [12, 13, 6, 11, 3, 10, 9, 8];

// inside spawnEnemy:
let ci = Math.min(waveNumber - 1, WAVE_COLORS.length - 1);
e.color = WAVE_COLORS[ci];

Math.min limita el índice para detenernos en el último color en vez de salir de los límites. La oleada 1 tiene azul claro, la oleada 8 en adelante tiene rojo.

Ejemplo Completo

Aquí está todo junto. El pool es más grande (200 slots para las oleadas posteriores), la dificultad escala por oleada, los enemigos cambian de color, y puedes hacer clic en cualquier lugar para mover el objetivo. Los nuevos enemigos apuntan a la nueva posición mientras los que ya están en vuelo mantienen su rumbo original — cada uno almacena el objetivo al que apuntaba cuando fue generado.

engine.scope((scope) => {
    const { start, cls, circfill, line, text, rnd, randomIntegerBetween, click, mouse } = scope;

    const MAX_ENEMIES = 200;
    const PAUSE_DURATION = 120;
    const WAVE_COLORS = [12, 13, 6, 11, 3, 10, 9, 8];

    let pool = [];
    for (let i = 0; i < MAX_ENEMIES; i++) {
        pool.push({ active: false, x: 0, y: 0, vx: 0, vy: 0, tx: 0, ty: 0, color: 0 });
    }

    let activeCount = 0;
    let targetX = 64;
    let targetY = 64;
    let waveNumber = 1;
    let enemiesToSpawn = 0;
    let spawnTimer = 0;
    let spawnInterval = 0;
    let speed = 0;
    let pauseTimer = PAUSE_DURATION;
    let pausing = true;

    function configureWave() {
        enemiesToSpawn = 3 + waveNumber * 2;
        spawnInterval = Math.max(10, 60 - waveNumber * 5);
        speed = 0.3 + waveNumber * 0.05;
    }

    configureWave();

    function acquire() {
        for (let i = 0; i < pool.length; i++) {
            if (!pool[i].active) {
                pool[i].active = true;
                activeCount++;
                return pool[i];
            }
        }
        return null;
    }

    function release(e) {
        e.active = false;
        activeCount--;
    }

    function spawnEnemy() {
        let e = acquire();
        if (!e) return;

        let edge = randomIntegerBetween(0, 3);
        if (edge === 0) { e.x = rnd(128); e.y = 0; }
        else if (edge === 1) { e.x = 128; e.y = rnd(128); }
        else if (edge === 2) { e.x = rnd(128); e.y = 128; }
        else { e.x = 0; e.y = rnd(128); }

        e.tx = targetX;
        e.ty = targetY;

        let dx = e.tx - e.x;
        let dy = e.ty - e.y;
        let dist = Math.sqrt(dx * dx + dy * dy);
        e.vx = (dx / dist) * speed;
        e.vy = (dy / dist) * speed;

        let ci = Math.min(waveNumber - 1, WAVE_COLORS.length - 1);
        e.color = WAVE_COLORS[ci];
    }

    function update() {
        if (click()) {
            let m = mouse();
            targetX = m.x;
            targetY = m.y;
        }

        if (pausing) {
            pauseTimer--;
            if (pauseTimer <= 0) {
                pausing = false;
                spawnTimer = 0;
            }
            return;
        }

        if (enemiesToSpawn > 0) {
            spawnTimer--;
            if (spawnTimer <= 0) {
                spawnEnemy();
                enemiesToSpawn--;
                spawnTimer = spawnInterval;
            }
        }

        for (let i = 0; i < pool.length; i++) {
            if (!pool[i].active) continue;
            pool[i].x += pool[i].vx;
            pool[i].y += pool[i].vy;

            let dx = pool[i].tx - pool[i].x;
            let dy = pool[i].ty - pool[i].y;
            if (dx * dx + dy * dy < 16) release(pool[i]);
        }

        if (enemiesToSpawn <= 0 && activeCount === 0) {
            waveNumber++;
            configureWave();
            pauseTimer = PAUSE_DURATION;
            pausing = true;
        }
    }

    function draw() {
        cls(0);

        line(targetX - 5, targetY, targetX + 5, targetY, 7);
        line(targetX, targetY - 5, targetX, targetY + 5, 7);

        for (let i = 0; i < pool.length; i++) {
            if (!pool[i].active) continue;
            circfill(pool[i].x, pool[i].y, 2, pool[i].color);
        }

        if (pausing) {
            text('WAVE ' + waveNumber, 45, 60, 7);
        }

        text('WAVE: ' + waveNumber, 2, 2, 7);
        text('ENEMIES: ' + activeCount, 2, 10, 7);
        text('FPS: ' + scope.currentFps, 2, 18, 7);
    }

    start({ sprites: {}, sounds: {}, update, draw, target });
});
Spawning & Waves: Complete Example
Haz clic en cualquier lugar para mover el objetivo — los nuevos enemigos apuntan a la nueva posición

El objetivo movible con clic es la única mecánica nueva. click() devuelve true en el frame en que se presiona un botón del ratón, mouse() da la posición del cursor. Cada enemigo almacena tx y ty — su objetivo al momento de aparecer — así que mover la mira durante una oleada no redirige a los enemigos que ya están en vuelo. Dibujamos la mira con dos llamadas a line() en vez de un círculo para distinguirla de los enemigos.

Para Seguir Explorando

  • Patrones de aparición — en vez de bordes aleatorios, generar en formaciones. Todos desde un lado, pinzas desde dos lados, o rodeando desde los cuatro. Elegir el patrón por oleada para dar variedad.
  • Personaje jugable — reemplazar la mira con un jugador móvil que tenga detección de colisiones. Los enemigos que lleguen al jugador causan daño. Ahora tienes un juego de supervivencia.
  • Múltiples tipos de enemigos — diferentes tamaños, velocidades, colores y patrones de movimiento. Pequeños y rápidos que hacen zigzag, grandes y lentos que van en línea recta, enemigos que orbitan antes de acercarse.
  • Enemigos jefe — cada N oleadas, generar un enemigo grande y lento junto con los regulares. Darle más vida y un aspecto distintivo.
  • Power-ups — generar coleccionables entre oleadas o durante ellas. Aumentos de velocidad, ralentizar enemigos, limpiar pantalla. El mismo patrón de pool funciona para estos también.