Skip to content

Your First Game

In this tutorial, you will build a simple "dodge the falling blocks" game using the Engine class. This covers the core patterns you will use in any game: defining components, writing systems, handling input, and rendering to a canvas.

What We're Building

A player-controlled paddle at the bottom of the screen that must avoid falling blocks. The game speeds up over time and tracks your score (time survived).

Prerequisites

Make sure you have a project set up following the Project Setup guide, or create a minimal one:

bash
pnpm create vite dodge-game -- --template vanilla-ts
cd dodge-game
pnpm add @web-engine-dev/engine

One import for everything

The @web-engine-dev/engine umbrella package re-exports all engine modules. For a small game like this, it's the simplest approach. For production apps where bundle size matters, import from individual packages like @web-engine-dev/ecs and @web-engine-dev/math.

Step 1: Define Components and Resources

Create src/components.ts:

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

// Position and size for all game objects
export const Position = defineComponent('Position', { x: 'f32', y: 'f32' });
export const Size = defineComponent('Size', { width: 'f32', height: 'f32' });
export const Velocity = defineComponent('Velocity', { x: 'f32', y: 'f32' });
export const Color = defineComponent('Color', { r: 'f32', g: 'f32', b: 'f32' });

// Tags to distinguish entity types
export const Player = defineTag('Player');
export const Block = defineTag('Block');

// Game state resource (singleton, shared across systems)
export const GameState = defineResource<{
  score: number;
  speed: number;
  spawnTimer: number;
  spawnInterval: number;
  gameOver: boolean;
}>('GameState');

// Input state resource
export const InputState = defineResource<{
  left: boolean;
  right: boolean;
}>('InputState');

// Canvas context resource
export const CanvasCtx = defineResource<{
  ctx: CanvasRenderingContext2D;
  width: number;
  height: number;
}>('CanvasCtx');

Why resources?

Resources are global singletons accessed via world.getResource(). Game state, input, and the canvas context don't belong to any entity -- they are shared data. Unlike component data, resource mutations persist automatically without calling insert().

Step 2: Write Systems

Create src/systems.ts:

typescript
// src/systems.ts
import { defineSystem, queryBuilder, type World } from '@web-engine-dev/engine';
import {
  Position, Size, Velocity, Color,
  Player, Block,
  GameState, InputState, CanvasCtx,
} from './components.js';

// ---- Queries (defined once, reused every frame) ----

const playerQuery = queryBuilder().with(Position, Size, Player).build();
const blockQuery = queryBuilder().with(Position, Size, Velocity, Color, Block).build();
const renderQuery = queryBuilder().with(Position, Size, Color).build();

// ---- AABB collision helper ----

function overlaps(
  ax: number, ay: number, aw: number, ah: number,
  bx: number, by: number, bw: number, bh: number,
): boolean {
  return ax < bx + bw && ax + aw > bx && ay < by + bh && ay + ah > by;
}

// ---- Systems ----

/** Move the player left/right based on keyboard input */
export const playerMoveSystem = defineSystem({
  name: 'PlayerMove',
  fn: (world: World, dt: number) => {
    const input = world.getResource(InputState);
    const canvas = world.getResource(CanvasCtx);
    const game = world.getResource(GameState);
    if (!input || !canvas || !game || game.gameOver) return;

    const speed = 400; // pixels/sec

    for (const { entity, components: [pos, size] } of world.run(playerQuery)) {
      if (input.left) pos.x -= speed * dt;
      if (input.right) pos.x += speed * dt;
      pos.x = Math.max(0, Math.min(canvas.width - size.width, pos.x));
      world.insert(entity, Position, pos);
    }
  },
});

/** Spawn falling blocks at intervals */
export const blockSpawnSystem = defineSystem({
  name: 'BlockSpawn',
  fn: (world: World, dt: number) => {
    const game = world.getResource(GameState);
    const canvas = world.getResource(CanvasCtx);
    if (!game || !canvas || game.gameOver) return;

    game.spawnTimer -= dt;
    if (game.spawnTimer > 0) return;

    game.spawnTimer = game.spawnInterval;

    const w = 30 + Math.random() * 50;
    const block = world.spawn();
    world.insert(block, Position, { x: Math.random() * (canvas.width - w), y: -40 });
    world.insert(block, Size, { width: w, height: 20 });
    world.insert(block, Velocity, { x: 0, y: game.speed });
    world.insert(block, Color, {
      r: 0.2 + Math.random() * 0.8,
      g: 0.2 + Math.random() * 0.3,
      b: 0.2 + Math.random() * 0.3,
    });
    world.insert(block, Block, {});
  },
});

/** Move blocks downward, despawn when off-screen */
export const blockMoveSystem = defineSystem({
  name: 'BlockMove',
  fn: (world: World, dt: number) => {
    const game = world.getResource(GameState);
    const canvas = world.getResource(CanvasCtx);
    if (!game || !canvas || game.gameOver) return;

    const toRemove: number[] = [];

    for (const { entity, components: [pos, _size, vel] } of world.run(blockQuery)) {
      pos.y += vel.y * dt;
      world.insert(entity, Position, pos);

      if (pos.y > canvas.height + 50) toRemove.push(entity);
    }

    for (const id of toRemove) world.despawn(id);
  },
});

/** Check player-block collisions */
export const collisionSystem = defineSystem({
  name: 'Collision',
  fn: (world: World) => {
    const game = world.getResource(GameState);
    if (!game || game.gameOver) return;

    let px = 0, py = 0, pw = 0, ph = 0, found = false;
    for (const { components: [pos, size] } of world.run(playerQuery)) {
      px = pos.x; py = pos.y; pw = size.width; ph = size.height;
      found = true;
    }
    if (!found) return;

    for (const { components: [pos, size] } of world.run(blockQuery)) {
      if (overlaps(px, py, pw, ph, pos.x, pos.y, size.width, size.height)) {
        game.gameOver = true;
        return;
      }
    }
  },
});

/** Increase difficulty over time */
export const difficultySystem = defineSystem({
  name: 'Difficulty',
  fn: (world: World, dt: number) => {
    const game = world.getResource(GameState);
    if (!game || game.gameOver) return;

    game.score += dt;
    game.speed = 150 + game.score * 5;
    game.spawnInterval = Math.max(0.3, 1.0 - game.score * 0.02);
  },
});

/** Render everything to canvas */
export const renderSystem = defineSystem({
  name: 'Render',
  fn: (world: World) => {
    const canvas = world.getResource(CanvasCtx);
    const game = world.getResource(GameState);
    if (!canvas || !game) return;

    const { ctx, width, height } = canvas;

    // Clear
    ctx.fillStyle = '#1a1a2e';
    ctx.fillRect(0, 0, width, height);

    // Draw entities
    for (const { components: [pos, size, color] } of world.run(renderQuery)) {
      const r = Math.floor(color.r * 255);
      const g = Math.floor(color.g * 255);
      const b = Math.floor(color.b * 255);
      ctx.fillStyle = `rgb(${r},${g},${b})`;
      ctx.fillRect(pos.x, pos.y, size.width, size.height);
    }

    // HUD
    ctx.fillStyle = '#fff';
    ctx.font = '20px monospace';
    ctx.textAlign = 'left';
    ctx.fillText(`Score: ${Math.floor(game.score)}`, 10, 30);

    if (game.gameOver) {
      ctx.fillStyle = 'rgba(0,0,0,0.7)';
      ctx.fillRect(0, 0, width, height);
      ctx.fillStyle = '#fff';
      ctx.font = '48px monospace';
      ctx.textAlign = 'center';
      ctx.fillText('GAME OVER', width / 2, height / 2 - 30);
      ctx.font = '24px monospace';
      ctx.fillText(`Final Score: ${Math.floor(game.score)}`, width / 2, height / 2 + 20);
      ctx.fillText('Press R to restart', width / 2, height / 2 + 60);
      ctx.textAlign = 'left';
    }
  },
});

Key patterns to note:

  • Queries are defined once at module level, not recreated every frame.
  • Systems receive dt (delta time) as a second argument from the scheduler.
  • world.insert() is called after modifying component data -- always.
  • Entities are despawned outside the iteration loop -- collect IDs first, then despawn.

Step 3: Wire It Up with the Engine

Replace src/main.ts:

typescript
// src/main.ts
import { createEngine, Game2DPreset, CoreSchedule } from '@web-engine-dev/engine';
import {
  Position, Size, Color, Player,
  GameState, InputState, CanvasCtx,
} from './components.js';
import {
  playerMoveSystem, blockSpawnSystem, blockMoveSystem,
  collisionSystem, difficultySystem, renderSystem,
} from './systems.js';

// ---- Canvas setup ----

const canvas = document.getElementById('game-canvas') as HTMLCanvasElement;
canvas.width = 800;
canvas.height = 600;
const ctx = canvas.getContext('2d')!;

// ---- Initialize game state in the world ----

function setupGame(world: import('@web-engine-dev/ecs').World) {
  world.insertResource(CanvasCtx, { ctx, width: canvas.width, height: canvas.height });
  world.insertResource(InputState, { left: false, right: false });
  world.insertResource(GameState, {
    score: 0,
    speed: 150,
    spawnTimer: 0,
    spawnInterval: 1.0,
    gameOver: false,
  });

  // Spawn the player paddle
  const player = world.spawn();
  world.insert(player, Position, { x: 375, y: 550 });
  world.insert(player, Size, { width: 80, height: 15 });
  world.insert(player, Color, { r: 0.2, g: 0.6, b: 1.0 });
  world.insert(player, Player, {});
}

// ---- Create the engine ----

const engine = createEngine(Game2DPreset, {
  onInit(engine) {
    // Register systems in execution order
    const s = engine.scheduler;
    s.addSystem(CoreSchedule.Update, playerMoveSystem);
    s.addSystem(CoreSchedule.Update, blockSpawnSystem);
    s.addSystem(CoreSchedule.Update, blockMoveSystem);
    s.addSystem(CoreSchedule.Update, collisionSystem);
    s.addSystem(CoreSchedule.Update, difficultySystem);
    s.addSystem(CoreSchedule.Update, renderSystem);

    setupGame(engine.world);
  },
});

// ---- Input handling ----

window.addEventListener('keydown', (e) => {
  const input = engine.world.getResource(InputState);
  if (!input) return;
  if (e.key === 'ArrowLeft' || e.key === 'a') input.left = true;
  if (e.key === 'ArrowRight' || e.key === 'd') input.right = true;

  // Restart on 'R' when game over
  if ((e.key === 'r' || e.key === 'R') && engine.world.getResource(GameState)?.gameOver) {
    // Despawn all entities and re-initialize
    for (const entity of engine.world.entities()) {
      engine.world.despawn(entity);
    }
    setupGame(engine.world);
  }
});

window.addEventListener('keyup', (e) => {
  const input = engine.world.getResource(InputState);
  if (!input) return;
  if (e.key === 'ArrowLeft' || e.key === 'a') input.left = false;
  if (e.key === 'ArrowRight' || e.key === 'd') input.right = false;
});

// ---- Start! ----

await engine.start();

Notice how much simpler this is compared to a manual game loop:

  • No requestAnimationFrame -- the engine manages the loop.
  • No manual time tracking -- Game2DPreset configures 60 FPS with fixed timestep.
  • No scheduler boilerplate -- just engine.scheduler.addSystem().
  • Lifecycle callbacks keep initialization organized.

Step 4: Add the HTML

Make sure your index.html has a canvas element:

html
<canvas id="game-canvas"></canvas>
<script type="module" src="/src/main.ts"></script>

Step 5: Run It

bash
pnpm dev

Open http://localhost:5173 in your browser. Use arrow keys or A/D to dodge the falling blocks.

What You've Learned

ConceptWhat You Used
EnginecreateEngine(Game2DPreset, callbacks) for managed game loop
ComponentsdefineComponent() for data, defineTag() for markers
Entitiesworld.spawn(), world.insert(), world.despawn()
QueriesqueryBuilder().with().build(), world.run(query)
SystemsdefineSystem() registered on CoreSchedule.Update
ResourcesdefineResource() for shared state (input, canvas, game config)
PresetsGame2DPreset for 60 FPS, fixed timestep configuration

Remember the Critical Pattern

Component data from world.get() and query iteration is always a copy. You must call world.insert() after modifying it. Resources from world.getResource() are direct references -- mutations persist automatically.

Next Steps

  • ECS Concepts -- Deep dive into archetypes, change detection, observers, and parallel execution
  • Rendering -- Use the WebGPU renderer for hardware-accelerated graphics
  • Plugins -- Compose engine functionality with the plugin system
  • API Reference -- Full API documentation

Proprietary software. All rights reserved.