Skip to main content

How to Build a Spawning & Wave System

This tutorial was written in February 2026, for v2 of the engine.

Most action games need a way to throw enemies at the player. Not all at once — that's chaos — but in a rhythm. A few trickle in, the player deals with them, then the next batch shows up a little harder. That's a wave system, and it's one of the most reusable patterns in game dev.

We're gonna build one from scratch. No sprites, no player character, no combat — just a target at the center of the screen and enemies spawning from the edges to converge on it. By the end you'll have a working wave system with difficulty scaling you can drop into any game.

Spawning from the Edges

First we need enemies, and we need them coming from offscreen. The approach: pre-allocate a pool of enemy objects (same pattern as the object pooling tutorial), pick a random screen edge, place an enemy along it, and aim it at the center.

Here's the pool setup. Every enemy has a position, a velocity, and an active flag:

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 scans for an inactive slot and hands it back. release marks it inactive again. No allocation, no garbage — just recycling.

The spawning logic picks one of four edges and places the enemy at a random point along it. Then it figures out a direction toward the target and multiplies by speed:

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

Edge 0 is the top, 1 is the right, 2 is the bottom, 3 is the left. rnd(128) picks a random spot along whichever edge we chose. The direction math is standard stuff — subtract positions to get a vector, divide by its length to normalize, multiply by speed.

For the update loop, we spawn on a timer and move every active enemy. When one gets close enough to the target, we release it back to the 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 spawns one enemy every 20 frames — about three per second at 60 FPS. The despawn check uses squared distance (dx * dx + dy * dy < 16 is the same as "distance < 4") so we don't need a square root every frame.

Spawning & Waves: Edge Spawning
Watch enemies spawn from the edges and converge on the center

Red circles appear from random edges, drift toward the white target, and vanish when they arrive. The pool handles all of it — nothing gets created or destroyed after the initial setup.

Organizing into Waves

A constant stream works for testing, but games need rhythm. Spawn a burst, let the player breathe, then hit them again. That's what waves give us — a budget of enemies to spawn, a pause when they're all gone, then the next wave kicks in.

We need a few pieces of state to track where we are in the cycle:

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 is how many enemies this wave will spawn. SPAWN_INTERVAL is the gap between spawns in frames — 30 frames means two per second. PAUSE_DURATION is how long to wait between waves (120 frames = 2 seconds). We start paused so the player sees "WAVE 1" before anything happens.

The update loop now has two modes. During a pause, we count down and wait. During a wave, we spawn on a timer and decrement the budget:

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

The wave-end condition matters: the budget has to be exhausted AND all active enemies have to be gone. That way the last enemy finishes its journey before the pause starts. If we only checked the budget, the pause would kick in while enemies were still on screen.

Spawning & Waves: Wave System
Enemies now arrive in waves with pauses between

Now you can see the rhythm. Eight enemies trickle in, the screen clears, "WAVE 2" appears, and the next batch starts. Same spawning, but organized into bursts with breathing room.

Ramping Difficulty

Waves that never change get boring fast. We fix that by scaling three things as the wave number goes up: how many enemies spawn, how quickly they spawn, and how fast they move.

Let's wrap the wave config into a function that recalculates based on waveNumber:

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

Three formulas, three axes of difficulty:

More enemies. 3 + waveNumber * 2 means wave 1 gets 5, wave 5 gets 13, wave 10 gets 23. Linear growth — each wave adds exactly two more than the last.

Faster spawning. Math.max(10, 60 - waveNumber * 5) starts at 55 frames between spawns and shrinks by 5 each wave. Math.max clamps it so the interval never drops below 10 — without that floor it'd eventually hit zero and spawn every frame.

Faster enemies. 0.3 + waveNumber * 0.05 starts slow and adds a small bump each wave. By wave 10 they're moving at 0.8 pixels per frame — noticeably faster but not unfair.

We call configureWave() once at the start and again each time a new wave begins:

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

For visual feedback, we shift enemy colors by wave. An array from cool to warm — blues early, reds later — makes it obvious that things are escalating:

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 clamps the index so we stop at the last color instead of going out of bounds. Wave 1 gets light blue, wave 8 and beyond get red.

Complete Example

Here's everything together. The pool is bigger (200 slots for the later waves), difficulty scales per wave, enemies shift color, and you can click anywhere to move the target. New enemies aim at the new position while in-flight enemies keep their original heading — each one stores the target it was aimed at when it spawned.

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
Click anywhere to move the target — new enemies re-aim at the new position

The click-to-move target is the only new mechanic. click() returns true on the frame a mouse button goes down, mouse() gives the cursor position. Each enemy stores tx and ty — its target at spawn time — so moving the crosshair mid-wave doesn't redirect enemies already in flight. We draw the crosshair with two line() calls instead of a circle to tell it apart from the enemies.

Going Further

  • Spawn patterns — instead of random edges, spawn in formations. All from one side, pincers from two sides, or a surround from all four. Pick the pattern per wave for variety.
  • Player character — replace the crosshair with a moving player that has collision detection. Enemies that reach the player deal damage. Now you've got a survival game.
  • Multiple enemy types — different sizes, speeds, colors, and movement patterns. Small fast ones that zigzag, big slow ones that take a straight line, enemies that orbit before closing in.
  • Boss enemies — every Nth wave, spawn a large, slow enemy alongside the regulars. Give it more health and a distinct visual.
  • Power-ups — spawn collectibles between waves or during them. Speed boosts, slow-all-enemies, screen clears. Same pool pattern works for these too.