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 devOpen 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.