Skip to content

Spawn enemies in waves ​

Goal ​

Copy templates/blank. Place two TargetPoints and a Spawner. Two waves produce pink enemy actors; K destroys one for this isolated exercise.

Files you will edit ​

  • src/game.ts
  • src/level.json

Steps ​

  1. Create src/level.json. points is a TargetPoint tag; it is not an array of coordinates. delay is a pre-wave delay in seconds.
json
{
  "format": 1,
  "actors": [
    {
      "class": "MeshActor",
      "position": [0, -0.5, 0],
      "props": {
        "asset": "cube-muted",
        "scale": [20, 1, 20],
        "collision": "block",
        "halfExtents": [10, 0.5, 10]
      }
    },
    {
      "class": "TargetPoint",
      "position": [-2, 1, 0],
      "tags": ["enemy-spawns"]
    },
    {
      "class": "TargetPoint",
      "position": [2, 1, 0],
      "tags": ["enemy-spawns"]
    },
    {
      "class": "Spawner",
      "name": "waves",
      "position": [0, 1, 0],
      "props": {
        "actorClass": "WaveEnemy",
        "points": "enemy-spawns",
        "waves": [
          {
            "count": 2,
            "every": 0.5
          },
          {
            "count": 3,
            "every": 0.5,
            "delay": 1
          }
        ],
        "maxAlive": 2
      }
    }
  ]
}
  1. Replace src/game.ts. Register the custom class before validation. The Spawner tracks each child and notices destruction on the next actor step; no manual waves.killed call is needed. The example enemies stand still; add movement or combat hooks to WaveEnemy for your game.
ts
import {
  actors,
  defineActor,
  defineGame,
  destroyActor,
  mesh,
  Spawner,
  spawnerWaves,
  MeshActor,
  TargetPoint,
  validateLevel,
  type GameContext,
} from 'gameable';
import settings from '../settings.json';
import data from './level.json';

void Spawner;
void TargetPoint;
void MeshActor;
const WaveEnemy = defineActor('WaveEnemy', {
  components: { root: mesh('cube-pink') },
  tags: ['enemy'],
});
const eye = { x: 0, y: 6, z: 10 };
const target = { x: 0, y: 0, z: 0 };
const identity = { x: 0, y: 0, z: 0, w: 1 };
const model = { text: { alive: 0, finished: false, controls: 'K: remove one enemy' } };
function battle(ctx: GameContext): void {
  if (ctx.input.pressed('KeyK')) {
    const enemies = actors.ofClass(WaveEnemy);
    if (enemies.length > 0) destroyActor(ctx, enemies[0]);
  }
  const spawner = actors.named('waves');
  if (!spawner) return;
  const waves = spawnerWaves(spawner);
  model.text.alive = waves.alive();
  model.text.finished = waves.finished();
  ctx.hud.set(model);
}
export default defineGame({
  features: settings.features,
  world: settings.world,
  assets: ['cube-pink', 'cube-muted'],
  level: validateLevel(data),
  init(ctx) {
    ctx.camera.set(eye, identity);
    ctx.camera.lookAt(target);
  },
  systems: [{ on: 'authority', run: battle }],
});
  1. For a weapon, use applyDamage and explicitly destroy enemies when your death rule fires: Health reaching zero alone does not remove an actor. autoStart: false lets a trigger call spawnerWaves(spawner).start(ctx). Stopping waves stops future spawns, leaving living children in the world. The actor runtime saves wave progress and child tracking automatically.

Verify ​

From the copied game root run npx tsc --noEmit -p tsconfig.json, then npm run build (in this checkout use npm run build -w templates/blank). Blank is a bundle template, with no dev-server script: open the resulting bundle in the common Gameable player used by your app (examples/play/README.md describes local hosting).

Check it: without K, alive reaches 2 and no second wave starts. Destroy both: the second wave waits one second and starts. Its count is 3, but maxAlive: 2 keeps at most two alive at once; remove one to allow the third spawn. Remove all three and finished becomes true. Later spawns can share TargetPoint poses.

See also ​