Skip to content

Tutorial: Space Shooter

In this tutorial you'll build a classic top-down space shooter. By the end you'll have:

  • A player ship that moves and shoots
  • Enemy ships that fly in from the top
  • Collision detection between bullets and enemies
  • A score counter and game-over screen

Estimated time: 45 minutes
Difficulty: Beginner

1. Project Setup

bash
pnpm create @web-engine-dev/game@latest space-shooter
cd space-shooter
pnpm dev

Open src/main.ts, this is your entry point.

2. Define Components

Components are plain data. We'll define them first, then attach them to entities.

typescript
// src/components.ts
import { defineComponent } from '@web-engine-dev/ecs';

// Position and velocity (2D) - use typed field descriptors, not default values
export const Position = defineComponent('Position', { x: 'f32', y: 'f32' });
export const Velocity = defineComponent('Velocity', { x: 'f32', y: 'f32' });

// Health
export const Health = defineComponent('Health', { current: 'f32', max: 'f32' });

// Tags and gameplay data
export const Player = defineComponent('Player', {});
export const Enemy = defineComponent('Enemy', { points: 'u32', speed: 'f32' });
export const Bullet = defineComponent('Bullet', { damage: 'f32', fromPlayer: 'u8' });
export const Collider = defineComponent('Collider', { radius: 'f32' });

3. Spawn the Player

typescript
// src/systems/spawn.ts
import { type World } from '@web-engine-dev/ecs';
import { Position, Velocity, Health, Player, Collider } from '../components.js';

export function spawnPlayer(world: World) {
  world.spawn(
    [Position, { x: 400, y: 550 }],
    [Velocity, { x: 0, y: 0 }],
    [Health, { current: 3, max: 3 }],
    [Collider, { radius: 20 }],
    [Player, {}]
  );
}

4. Player Movement System

typescript
// src/systems/player-movement.ts
import { type World } from '@web-engine-dev/ecs';
import { InputManagerResource } from '@web-engine-dev/input';
import { Position, Velocity, Player } from '../components.js';

const SPEED = 200;

export function playerMovementSystem(world: World) {
  const inputMgr = world.getResource(InputManagerResource);

  const dx =
    (inputMgr.getActionDigital('right') ? 1 : 0) - (inputMgr.getActionDigital('left') ? 1 : 0);
  const dy =
    (inputMgr.getActionDigital('down') ? 1 : 0) - (inputMgr.getActionDigital('up') ? 1 : 0);

  const query = world.query().with(Position, Velocity, Player).build();
  for (const result of world.run(query)) {
    const entity = result.entity;
    world.insert(entity, Velocity, { x: dx * SPEED, y: dy * SPEED });
  }
}

5. Movement System (applies velocity to all entities)

typescript
// src/systems/movement.ts
import { type World, CurrentTime } from '@web-engine-dev/ecs';
import { Position, Velocity } from '../components.js';

export function movementSystem(world: World) {
  const time = world.getResource(CurrentTime);

  const query = world.query().with(Position, Velocity).build();
  for (const result of world.run(query)) {
    const [pos, vel] = result.components;
    const entity = result.entity;

    world.insert(entity, Position, {
      x: pos.x + vel.x * time.delta,
      y: pos.y + vel.y * time.delta,
    });
  }
}

6. Shooting

typescript
// src/systems/shooting.ts
import { type World, CurrentTime } from '@web-engine-dev/ecs';
import { Position, Player, Bullet, Velocity, Collider } from '../components.js';
import { InputManagerResource } from '@web-engine-dev/input';

let cooldown = 0;
const FIRE_RATE = 0.15; // seconds between shots

export function shootingSystem(world: World) {
  const time = world.getResource(CurrentTime);
  const inputMgr = world.getResource(InputManagerResource);
  cooldown -= time.delta;

  const isFiring = inputMgr.getActionDigital('fire');

  if (isFiring && cooldown <= 0) {
    cooldown = FIRE_RATE;
    const cmd = world.commands();

    const playerQuery = world.query().with(Position, Player).build();
    for (const result of world.run(playerQuery)) {
      const [pos] = result.components;
      cmd.spawnBundle([
        [Position, { x: pos.x, y: pos.y - 24 }],
        [Velocity, { x: 0, y: -500 }],
        [Bullet, { damage: 1, fromPlayer: 1 }],
        [Collider, { radius: 6 }],
      ]);
      break;
    }
  }
}

7. Enemy Spawner

typescript
// src/systems/enemy-spawner.ts
import { type World, CurrentTime } from '@web-engine-dev/ecs';
import { Position, Velocity, Health, Enemy, Collider } from '../components.js';

let spawnTimer = 0;
let spawnInterval = 1.5;

export function enemySpawner(world: World) {
  const time = world.getResource(CurrentTime);
  spawnTimer -= time.delta;

  // Increase difficulty over time
  spawnInterval = Math.max(0.4, 1.5 - time.elapsed * 0.05);

  if (spawnTimer <= 0) {
    spawnTimer = spawnInterval;
    const x = 50 + Math.random() * 700;
    world.commands().spawnBundle([
      [Position, { x, y: -30 }],
      [Velocity, { x: 0, y: 80 + Math.random() * 60 }],
      [Health, { current: 2, max: 2 }],
      [Enemy, { points: 10 + Math.floor(time.elapsed * 0.5), speed: 80 }],
      [Collider, { radius: 24 }],
    ]);
  }
}

8. Collision System

typescript
// src/systems/collision.ts
import { type World, type Commands } from '@web-engine-dev/ecs';
import { Position, Collider, Bullet, Enemy, Health, Player } from '../components.js';
import { Score } from '../resources.js';

function overlaps(ax: number, ay: number, ar: number, bx: number, by: number, br: number) {
  const dx = ax - bx,
    dy = ay - by;
  return dx * dx + dy * dy < (ar + br) * (ar + br);
}

export function collisionSystem(world: World) {
  const score = world.getResource(Score);
  const cmd = world.commands();

  // Snapshot queries into arrays for O(n^2) comparison
  const bullets = [...world.run(world.query().with(Position, Collider, Bullet).build())];
  const enemies = [...world.run(world.query().with(Position, Collider, Enemy, Health).build())];

  for (const bResult of bullets) {
    const [bPos, bCol, bul] = bResult.components;
    const bEntity = bResult.entity;
    if (!bul.fromPlayer) continue;

    for (const eResult of enemies) {
      const [ePos, eCol, , eHealth] = eResult.components;
      const eEntity = eResult.entity;

      if (overlaps(bPos.x, bPos.y, bCol.radius, ePos.x, ePos.y, eCol.radius)) {
        cmd.despawn(bEntity);
        const newHp = eHealth.current - bul.damage;
        if (newHp <= 0) {
          const enemy = world.get(eEntity, Enemy)!;
          score.value += enemy.points;
          cmd.despawn(eEntity);
        } else {
          world.insert(eEntity, Health, { ...eHealth, current: newHp });
        }
        break;
      }
    }
  }
}

9. Cleanup Out-of-Bounds Entities

typescript
// src/systems/cleanup.ts
import { type World, type Commands } from '@web-engine-dev/ecs';
import { Position, Bullet, Enemy } from '../components.js';

export function cleanupSystem(world: World) {
  const cmd = world.commands();

  const bulletQuery = world.query().with(Position, Bullet).build();
  for (const result of world.run(bulletQuery)) {
    const [pos] = result.components;
    if (pos.y < -50 || pos.y > 650 || pos.x < -50 || pos.x > 850) {
      cmd.despawn(result.entity);
    }
  }

  const enemyQuery = world.query().with(Position, Enemy).build();
  for (const result of world.run(enemyQuery)) {
    const [pos] = result.components;
    if (pos.y > 650) cmd.despawn(result.entity);
  }
}

10. Wire It All Together

typescript
// src/main.ts
import { createEngine, CoreSchedule } from '@web-engine-dev/engine';
import { createInputPlugin, createSpritePlugin } from '@web-engine-dev/engine';
import { InputManagerResource } from '@web-engine-dev/input';

import { spawnPlayer } from './systems/spawn.js';
import { playerMovementSystem } from './systems/player-movement.js';
import { movementSystem } from './systems/movement.js';
import { shootingSystem } from './systems/shooting.js';
import { enemySpawner } from './systems/enemy-spawner.js';
import { collisionSystem } from './systems/collision.js';
import { cleanupSystem } from './systems/cleanup.js';
import { Score, GameState } from './resources.js';

const engine = createEngine({ autoStart: false });
engine.addPlugin(createInputPlugin()).addPlugin(createSpritePlugin());

const world = engine.world;
world.insertResource(Score, { value: 0 });
world.insertResource(GameState, { phase: 'playing' });

// Register input bindings in a startup system
world.addSystemToSchedule(CoreSchedule.Startup, (w) => {
  w.getResource(InputManagerResource).registerActionMap({
    name: 'gameplay',
    actions: [
      {
        name: 'up',
        valueType: 'button',
        bindings: [
          { type: 'keyboard', key: 'ArrowUp' },
          { type: 'keyboard', key: 'KeyW' },
        ],
      },
      {
        name: 'down',
        valueType: 'button',
        bindings: [
          { type: 'keyboard', key: 'ArrowDown' },
          { type: 'keyboard', key: 'KeyS' },
        ],
      },
      {
        name: 'left',
        valueType: 'button',
        bindings: [
          { type: 'keyboard', key: 'ArrowLeft' },
          { type: 'keyboard', key: 'KeyA' },
        ],
      },
      {
        name: 'right',
        valueType: 'button',
        bindings: [
          { type: 'keyboard', key: 'ArrowRight' },
          { type: 'keyboard', key: 'KeyD' },
        ],
      },
      { name: 'fire', valueType: 'button', bindings: [{ type: 'keyboard', key: 'Space' }] },
    ],
  });
});

// Spawn player on startup
world.addSystemToSchedule(CoreSchedule.Startup, spawnPlayer);

// Register update systems
world
  .addSystem(playerMovementSystem)
  .addSystem(shootingSystem)
  .addSystem(enemySpawner)
  .addSystem(movementSystem)
  .addSystem(collisionSystem)
  .addSystem(cleanupSystem);

await engine.start();

What's Next

You have a working space shooter! Try extending it:

  • Add explosion particles using @web-engine-dev/particles
  • Add enemy shooting, a system that fires bullets downward periodically
  • Add a boss wave, a large enemy that takes many hits and fires patterns
  • Add power-ups, speed boost, triple-shot, shield
  • Play a sound effect on shoot and enemy death using @web-engine-dev/audio

Continue with the Platformer Tutorial to learn about physics and tilemaps.

Proprietary software. All rights reserved.