Core Game Loop
Every game is fundamentally a loop: read input, update state, render. Understanding how Web Engine Dev structures this loop is the single most important concept to internalize before building anything.
The World
World is the root container for everything in your game. It holds all entities, their components, all systems, all resources, and all events. There is typically one World per game.
import { World } from '@web-engine-dev/ecs';
const world = new World();Systems: Where Logic Lives
Logic in an ECS never lives in components (components are pure data). It lives in systems, plain functions that query the world and transform component data.
import { type World } from '@web-engine-dev/ecs';
import { Position, Velocity } from './components.js';
function MovementSystem(world: World): void {
const deltaTime = 1 / 60; // get from time resource in practice
const motionQuery = world.query().with(Position, Velocity).build();
for (const result of world.run(motionQuery)) {
const [pos, vel] = result.components;
const entity = result.entity;
// world.get() returns a COPY: mutate it, then write back
const newPos = {
x: pos.x + vel.x * deltaTime,
y: pos.y + vel.y * deltaTime,
};
world.insert(entity, Position, newPos); // persist the mutation
}
}Critical: Component Data is Copied
world.get(entity, Component) and queries return copies of component data, not references. You must call world.insert(entity, Component, newData) to persist any changes. Forgetting this is the most common silent bug in ECS code.
Defining Components
Components are plain data objects created with defineComponent:
import { defineComponent } from '@web-engine-dev/ecs';
// Schema uses typed field descriptors (NOT factory callbacks)
export const Position = defineComponent('Position', { x: 'f32', y: 'f32' });
export const Velocity = defineComponent('Velocity', { x: 'f32', y: 'f32' });
export const Health = defineComponent('Health', { current: 'f32', max: 'f32' });
export const Player = defineComponent('Player', {}); // tag component
export const Enemy = defineComponent('Enemy', { type: 'u32', damage: 'f32' });Registering Systems with the Scheduler
The scheduler controls system ordering and dependencies. Add systems with world.addSystem():
import { World, CoreSchedule } from '@web-engine-dev/ecs';
const world = new World();
// Systems run in the Update schedule by default
world.addSystem(InputSystem);
world.addSystem(PlayerMovementSystem);
world.addSystem(EnemyAISystem);
// For ordering across schedule phases use addSystemToSchedule directly
world.addSystemToSchedule(CoreSchedule.FixedUpdate, PhysicsSystem);
world.addSystemToSchedule(CoreSchedule.PostUpdate, AnimationSystem);
world.addSystemToSchedule(CoreSchedule.Last, RenderSystem);The Main Loop
Systems run in phases managed by world.runSchedule(). Each call executes all systems registered to that schedule for one tick:
import { World, CoreSchedule } from '@web-engine-dev/ecs';
const world = new World();
// ... register systems ...
world.runStartup(); // Run once: PreStartup, Startup, PostStartup
let lastTime = performance.now();
function gameLoop(timestamp: number) {
const delta = (timestamp - lastTime) / 1000; // seconds
lastTime = timestamp;
// Run per-frame schedules in order
world.runSchedule(CoreSchedule.First, delta);
world.runSchedule(CoreSchedule.PreUpdate, delta);
world.runSchedule(CoreSchedule.Update, delta);
world.runSchedule(CoreSchedule.PostUpdate, delta);
world.runSchedule(CoreSchedule.Last, delta);
requestAnimationFrame(gameLoop);
}
requestAnimationFrame(gameLoop);Read Time Inside Systems
Systems receive deltaTime via world.getResource(CurrentTime) (imported from @web-engine-dev/ecs, injected by the engine). The Time interface (delta, elapsed, frame) is read-only from user code - the engine updates it internally each frame.
Using the Engine Package
For most games, use the @web-engine-dev/engine umbrella package which wires everything up for you:
import { createEngine } from '@web-engine-dev/engine';
const engine = await createEngine({
canvas: document.getElementById('canvas') as HTMLCanvasElement,
width: 1280,
height: 720,
});
// engine.world is your World instance
const { world } = engine;
// Register your game systems
world.addSystem(PlayerSystem);
world.addSystem(EnemySystem);
// Start the loop
engine.start();Spawn Points: Creating Entities
Entities are created with world.spawn() and built with component bundles:
// Spawn a player entity
const player = world.spawn(
[Position, { x: 100, y: 100 }],
[Velocity, { x: 0, y: 0 }],
[Health, { current: 100, max: 100 }],
[Player, {}],
[Sprite, { texture: 'player.png', width: 32, height: 32 }]
);
// Spawn an enemy
const enemy = world.spawn(
[Position, { x: 400, y: 300 }],
[Velocity, { x: 0, y: 0 }],
[Health, { current: 50, max: 50 }],
[Enemy, { type: 'grunt', damage: 10 }]
);
// Despawn when done
world.despawn(enemy);Commands: Deferred Mutations
Inside a system, never spawn/despawn entities directly, use commands to defer the mutation until after the current update cycle:
function BulletSpawnSystem(world: World): void {
const cmd = world.commands();
// Deferred: will execute after all systems complete
cmd.spawnBundle([
[Position, { x: 0, y: 0 }],
[Velocity, { x: 0, y: -500 }],
[Bullet, { damage: 25 }],
]);
// Despawn bullets that left the screen
const bulletQuery = world.query().with(Position, Bullet).build();
for (const result of world.run(bulletQuery)) {
const [pos] = result.components;
if (pos.y < 0) {
cmd.despawn(result.entity);
}
}
}Events: Communication Between Systems
Systems communicate via events, double-buffered queues that are cleared each frame:
import { defineEvent } from '@web-engine-dev/events';
// Define event types
export const DamageEvent = defineEvent<{ target: Entity; amount: number }>('Damage');
export const DeathEvent = defineEvent<{ entity: Entity }>('Death');
// Writing events (e.g. from a collision system)
function CollisionSystem(world: World): void {
const damages = world.eventWriter(DamageEvent);
const bulletQuery = world.query().with(Bullet, Position).build();
for (const result of world.run(bulletQuery)) {
const [bullet] = result.components;
// check collision with enemies...
damages.send({ target: enemyEntity, amount: bullet.damage });
}
}
// Reading events (e.g. in a health system)
function HealthSystem(world: World): void {
const damages = world.eventReader(DamageEvent);
const deaths = world.eventWriter(DeathEvent);
for (const { target, amount } of damages.read()) {
const health = world.get(target, Health);
const next = { ...health, current: health.current - amount };
world.insert(target, Health, next);
if (next.current <= 0) {
deaths.send({ entity: target });
}
}
}Full Minimal Game Example
import { createEngine } from '@web-engine-dev/engine';
import { defineComponent } from '@web-engine-dev/ecs';
import { InputManager } from '@web-engine-dev/input';
// --- Components ---
const Position = defineComponent('Position', { x: 'f32', y: 'f32' });
const Velocity = defineComponent('Velocity', { x: 'f32', y: 'f32' });
const Player = defineComponent('Player', {});
// --- Systems ---
function PlayerInputSystem(world) {
const speed = 200;
const move = inputManager.getAction('move');
const playerQuery = world.query().with(Velocity, Player).build();
for (const result of world.run(playerQuery)) {
const newVel =
move?.type === 'axis2d' ? { x: move.x * speed, y: move.y * speed } : { x: 0, y: 0 };
world.insert(result.entity, Velocity, newVel);
}
}
function MovementSystem(world) {
const time = world.getResource(CurrentTime); // import CurrentTime from '@web-engine-dev/ecs'
const motionQuery = world.query().with(Position, Velocity).build();
for (const result of world.run(motionQuery)) {
const [pos, vel] = result.components;
world.insert(result.entity, Position, {
x: pos.x + vel.x * time.delta,
y: pos.y + vel.y * time.delta,
});
}
}
// --- Bootstrap ---
const engine = await createEngine({ canvas, width: 800, height: 600 });
const { world } = engine;
world.addSystem(PlayerInputSystem);
world.addSystem(MovementSystem);
world.spawn([Position, { x: 400, y: 300 }], [Velocity, { x: 0, y: 0 }], [Player, {}]);
engine.start();Next Steps
- Scripting, add gameplay logic with the higher-level script layer
- Entities & Components, patterns for common game objects
- Input Handling, keyboard, gamepad, and touch input