Skip to content

Build a moving, drawn scene

A walkthrough from an empty folder to a rectangle that moves across the screen and is drawn, using nothing but shipped primitives. It is a single file built up over seven steps. The finished file is type-checked and run against the current source on every test run, so what you copy is what works.

The engine is pre-release and not published to npm, so it is consumed as a sibling folder through a file: install:

{
"dependencies": {
"@pierre/ecs": "file:../Entity-Cornponent-System-Engine"
}
}

The package’s exports map points straight at TypeScript sources, so a TypeScript-aware bundler — Vite is what every example uses — needs no build step. Projects that neither bundle nor transpile need one.

The world is the registry everything else hangs off. The canvas is where the scene will be drawn in step 7.

import { EcsWorld } from '@pierre/ecs';
const WIDTH = 480;
const HEIGHT = 270;
const canvas = document.createElement('canvas');
canvas.width = WIDTH;
canvas.height = HEIGHT;
document.body.append(canvas);
const ctx2d = canvas.getContext('2d')!;
const world = new EcsWorld();

A world starts empty. It knows no components and no entities until you tell it.

Two components are enough for this: a position, and something to draw. Both ship with the engine — position lives in the transform module, renderable in the canvas renderer.

import { RenderableDef } from '@pierre/ecs/modules/render-canvas2d';
import { PositionDef } from '@pierre/ecs/modules/transform';
world.registerComponent(PositionDef);
world.registerComponent(RenderableDef);

Registering gives you a store for that component type and reserves its name in the world. Names must be unique per world, so this happens once, at startup.

spawn takes a template: a name for debugging, and the components to put on the new entity, keyed by the name each component registered under.

world.spawn({
name: 'box',
components: {
position: { x: 40, y: 120 },
renderable: { kind: 'rect', w: 32, h: 32, fill: '#3369ff' },
},
});

renderable declares that it requires position, so the two travel together.

A system is a function over the world, and what it receives is the tick context — an object your project defines. The narrowest useful shape is the world plus how long the last tick covered, so that is what this one declares. Step 6 creates the object and hands it to the system on every tick.

interface Sim {
dtMs: number;
world: EcsWorld;
}
const moveSystem = {
name: 'move',
run: (ctx: Sim): void => {
for (const [, position] of ctx.world.query(PositionDef))
position.x = (position.x + (60 * ctx.dtMs) / 1000) % WIDTH;
},
};

Two things worth noticing:

  • The system writes the component in place. The query hands out a live row, so assigning position.x updates the store and marks the entity changed for this tick. Once a world has a spatial index (world.enableSpatial), the indexed component moves through world.move instead, so the index follows — see world.
  • Movement is scaled by dtMs. The system does not assume a frame rate; what it is given is how long the last tick covered.

Nothing has run yet. Scheduler orders systems, a TickSource decides when ticks fire, and TickRunner performs the per-tick ceremony between them.

import { Scheduler, TickRunner } from '@pierre/ecs';
import { AnimationFrameTickSource } from '@pierre/ecs/modules/tick';
const sim: Sim = { dtMs: 0, world };
const scheduler = new Scheduler<Sim>().add(moveSystem);
const source = new AnimationFrameTickSource();
const runner = new TickRunner<Sim>({
scheduler,
source,
contextFactory: (info) => {
sim.dtMs = info.deltaMs ?? 16;
return sim;
},
getWorld: () => world,
});
runner.start();
  • AnimationFrameTickSource asks the browser for a frame and ticks once per frame; modules/tick ships three others, including a fixed-timestep one for physics that must not vary with frame rate.
  • contextFactory builds the object systems receive — here, the same sim object with the frame’s delta written into it. It runs before each tick and always writes dtMs first, so the 0 it starts from is never seen by a system. Reusing one object is a choice, not a rule: a fresh one per tick is equally valid.
  • getWorld tells the runner which world to flush at the end of each tick. A scene that also has a game event bus passes it as getEvents, so its events drain in the same flush; this one emits none.

Drawing is not a tick. The renderer runs on the browser’s paint loop, reading the world as it currently is.

import { Canvas2DRenderer } from '@pierre/ecs/modules/render-canvas2d';
const renderer = new Canvas2DRenderer();
function frame(): void {
ctx2d.fillStyle = '#0b0b0f';
ctx2d.fillRect(0, 0, WIDTH, HEIGHT);
renderer.render({ ctx2d, world });
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);

Canvas2DRenderer draws every entity that carries position and renderable; shapes, anchors, rotation, scale, opacity and z-order all come from the components the entity has. Nothing needs to be registered with the renderer.

That is the whole scene: a rectangle that drifts right at 60 pixels per second and wraps, drawn by the engine’s default renderer.

  • The model — what entities, components and archetypes actually are, and why the query above is cheap.
  • Ticks, frames and system order — why step 6 and step 7 are separate loops, and how to order more than one system.
  • Structural changes — what to expect when entities start gaining and losing components.
  • Module index — the modules that add sprites, input, collision, cameras and the rest.