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:
pnpm create vite dodge-game -- --template vanilla-ts
cd dodge-game
pnpm add @web-engine-dev/engineOne 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:
// 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:
// 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:
// 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 --
Game2DPresetconfigures 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:
<canvas id="game-canvas"></canvas>
<script type="module" src="/src/main.ts"></script>Step 5: Run It
pnpm devOpen http://localhost:5173 in your browser. Use arrow keys or A/D to dodge the falling blocks.
What You've Learned
| Concept | What You Used |
|---|---|
| Engine | createEngine(Game2DPreset, callbacks) for managed game loop |
| Components | defineComponent() for data, defineTag() for markers |
| Entities | world.spawn(), world.insert(), world.despawn() |
| Queries | queryBuilder().with().build(), world.run(query) |
| Systems | defineSystem() registered on CoreSchedule.Update |
| Resources | defineResource() for shared state (input, canvas, game config) |
| Presets | Game2DPreset 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