Skip to content

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.

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

typescript
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:

typescript
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():

typescript
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:

typescript
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:

typescript
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:

typescript
// 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:

typescript
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:

typescript
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

typescript
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

Proprietary software. All rights reserved.