Skip to content

Getting Started

This is a private project. Reach out to us at www.setsnail.com to get access.

This guide introduces the minimum setup needed to create a world, define components, spawn entities, and run systems.

Make sure you have Node 24.16.0+ and pnpm 10+, then install the aggregate package for your runtime. This guide uses the Pixi-based 2D engine:

Terminal window
pnpm add @shell/engine-2d

Everything starts with a World. It holds your entities, components, systems, and resources.

import { Engine2D, World } from '@shell/engine-2d';
const canvas = document.querySelector<HTMLCanvasElement>('#game')!;
const world = new World();
world.addPlugin(Engine2D({ canvas, resizeTo: window }));

@shell/engine-2d re-exports the core ECS, canonical transform APIs, Pixi, physics, and gizmo APIs. Game-level capabilities such as spatial navigation and tilemaps are installed separately. Use @shell/engine-minimal for renderer-neutral programs or @shell/engine-3d for Three.js.

Components are pure data. The engine aggregate provides canonical components such as Position. Define game-specific data with Define.component() and use StoreType to pick the backing storage:

import { Define, StoreType } from '@shell/engine-2d';
export const Velocity = Define.component('velocity').withSchema({
x: { type: StoreType.Float32 },
y: { type: StoreType.Float32 },
});

Systems contain your game logic. Declare which components you need with .queries(), then write the update function.

import { Define, Mut, Position, Schedule } from '@shell/engine-2d';
const Moving = Define.query({ data: [Mut(Position), Velocity] });
const MovementSystem = Define.system()
.queries({ moving: Moving })
.update(({ queries, time }) => {
queries.moving.forEach((entity) => {
entity.$position.x += entity.$velocity.x * time.delta;
entity.$position.y += entity.$velocity.y * time.delta;
});
});

Mut(Position) tells the engine you will write to the position.

Register the system on a schedule:

world.registerSystem(Schedule.Update, MovementSystem);

Entities are lightweight IDs. You can spawn them from prefabs inside systems or from the world directly.

import { Define, Position } from '@shell/engine-2d';
const PlayerPrefab = Define.prefab()
.with(Position, { x: 0, y: 0 })
.with(Velocity, { x: 100, y: 50 });
// Inside a system
commands.spawn(PlayerPrefab);

Mount the world to start the lifecycle. Startup systems run, then the tick loop begins.

await world.mount();

If you are writing a test or running a headless simulation, you can tick the scheduler manually:

const scheduler = world.get('scheduler');
scheduler.tick();

Here is a self-contained example that ties everything together:

import { Define, Engine2D, Mut, Position, Schedule, StoreType, World } from '@shell/engine-2d';
const Velocity = Define.component('velocity').withSchema({
x: { type: StoreType.Float32 },
y: { type: StoreType.Float32 },
});
const Moving = Define.query({ data: [Mut(Position), Velocity] });
const MovementSystem = Define.system()
.queries({ moving: Moving })
.update(({ queries, time }) => {
queries.moving.forEach((entity) => {
entity.$position.x += entity.$velocity.x * time.delta;
entity.$position.y += entity.$velocity.y * time.delta;
});
});
const canvas = document.querySelector<HTMLCanvasElement>('#game')!;
const world = new World();
world.addPlugin(Engine2D({ canvas, resizeTo: window }));
world.registerComponent(Velocity);
world.registerSystem(Schedule.Update, MovementSystem);
await world.mount();
// Spawn a player
const commands = world.get('commands');
commands.spawn(Define.prefab().with(Position, { x: 0, y: 0 }).with(Velocity, { x: 10, y: 0 }));