yelouafi/fnayr
fnayr
A lightweight, schema-driven Entity Component System (ECS) engine for TypeScript. Define your components with schemas, build worlds from JSON, and run game logic with systems, queries, events, and hierarchy — all fully typed.
Built on Vite + React.
Table of Contents
Getting Started
You'll need: Node.js 18+ and pnpm
pnpm install # grab dependencies
pnpm dev # start the dev server at http://localhost:5173That's it — open the URL and you're running.
Scripts
| Command | What it does |
|---|---|
pnpm dev |
Start dev server with HMR |
pnpm build |
Production build to dist/ |
pnpm preview |
Preview the production build |
pnpm typecheck |
TypeScript type checking |
pnpm test:run |
Run the test suite |
Schemas — Define Your Data
Before you can create worlds and entities, you describe what your components look like. The schema system gives you validation, defaults, and JSON round-tripping for free.
Building a Registry
A registry is just an object mapping component names to schemas. Use the s helper:
import { s } from "./src/engine";
const registry = {
Transform: s.object({
position: s.tuple([s.number(), s.number()]),
scale: s.number({ default: 1 }),
}),
Name: s.object({
label: s.string(),
}),
Visible: s.tag(), // marker component, no data
};Available types: s.number(), s.string(), s.boolean(), s.tuple(), s.object(), and s.tag().
Parsing & Serializing Worlds
Got a JSON file describing a world? Parse it into a validated structure, then serialize it back whenever you need to save:
import { parseWorld, serializeWorld } from "./src/engine";
const worldJson = {
version: 1,
entities: [
{
id: 1,
components: {
Transform: { position: [1, 2] },
Name: { label: "Hero" },
Visible: true,
},
},
],
};
// JSON -> validated world
const parsed = parseWorld(registry, worldJson, {
applyDefaults: true,
allowUnknownComponents: false,
validation: { allowUnknownProperties: false },
});
if (parsed.issues.length > 0) {
console.log(parsed.issues); // see what went wrong
}
// validated world -> JSON
const serialized = serializeWorld(registry, parsed.world, {
stripUnknownComponents: false,
});Common Patterns
| What you want | How to get it |
|---|---|
| Keep modded/unknown components intact | parseWorld(registry, json, { allowUnknownComponents: true }) |
| Allow freeform JSON inside an object | s.object({}, { allowUnknown: true }) |
| Serialize strictly without losing data | serializeWorld(registry, world, { stripUnknownComponents: false }) |
Runtime — Bring It to Life
Once you have a registry, the runtime API (under the ecs namespace) lets you create worlds, spawn entities, run systems, and react to changes — all fully typed against your schema.
Creating a World
import { ecs, s } from "./src/engine";
const registry = {
Position: s.object({ x: s.number(), y: s.number() }),
Velocity: s.object({ x: s.number(), y: s.number() }),
};
const world = ecs.createWorld(registry);
const entity = world.createEntity();
world.setComponent(entity, "Position", { x: 0, y: 0 });
world.setComponent(entity, "Velocity", { x: 2, y: 1 });Systems & the Scheduler
Systems are just functions. The scheduler calls them every frame with the world and a delta time:
const scheduler = new ecs.Scheduler(world);
scheduler.addSystem((world, dt) => {
for (const { entity, components } of world.query(["Position", "Velocity"])) {
world.setComponent(entity, "Position", {
x: components.Position.x + components.Velocity.x * dt,
y: components.Position.y + components.Velocity.y * dt,
});
}
});
scheduler.runFrame(0.5); // advance by 0.5 secondsTags
Tags are marker components with no data — perfect for flags like "Player" or "Visible":
const registry = {
Player: s.tag(),
Position: s.object({ x: s.number(), y: s.number() }),
};
const world = ecs.createWorld(registry);
const e = world.createEntity();
world.setComponent(e, "Player"); // no data needed
world.setComponent(e, "Position", { x: 0, y: 0 });In-Place Mutation
Need to tweak a component without replacing it? getMut gives you a direct reference and automatically flags it as changed for tracking:
const pos = world.getMut(entity, "Position");
if (pos) {
pos.x += 10; // mutate in place, change tracking picks it up
}Change Tracking
Every frame, the world knows exactly which entities had components added, removed, or updated:
scheduler.addSystem((world) => {
for (const entity of world.getAdded("Position")) {
// just got a Position this frame
}
for (const entity of world.getUpdated("Position")) {
// Position was modified this frame
}
for (const entity of world.getRemoved("Position")) {
// Position was taken away this frame
}
});Cached Queries
For performance-critical loops, cached queries maintain a live set of matching entities that stays up-to-date automatically:
const movers = world.createQuery(["Position", "Velocity"]);
scheduler.addSystem(() => {
for (const { entity, components } of movers) {
// only iterates matching entities — no filtering overhead
}
console.log(movers.size); // how many match right now
});System Phases
Group systems into named phases so they always run in a predictable order:
const scheduler = new ecs.Scheduler(world, ["input", "update", "render"]);
scheduler.addSystem("input", handleInput);
scheduler.addSystem("update", physics);
scheduler.addSystem("update", ai);
scheduler.addSystem("render", draw);
scheduler.disableSystem(ai); // temporarily skip
scheduler.enableSystem(ai); // bring it back
scheduler.removeSystem(draw); // gone for goodCommand Buffer
Destroying an entity while you're iterating over a query is a recipe for bugs. The commands argument defers those mutations until the system finishes:
scheduler.addSystem((world, dt, commands) => {
for (const { entity, components } of world.query(["Health"])) {
if (components.Health.hp <= 0) {
commands.destroyEntity(entity); // happens after this system returns
}
}
// Creating entities is immediate — you get a usable ID right away
const spawned = commands.createEntity();
commands.setComponent(spawned, "Position", { x: 0, y: 0 });
});Resources
Resources are world-level singletons — typed values that aren't attached to any entity:
const resources = {
Time: s.object({ elapsed: s.number() }),
Config: s.object({ gravity: s.number() }),
};
const world = ecs.createWorld(registry, { resources });
world.setResource("Time", { elapsed: 0 });
world.getResource("Time"); // { elapsed: 0 }
world.hasResource("Config"); // falseEvents
Frame-buffered typed events let systems talk to each other without tight coupling:
const events = {
Collision: s.object({ a: s.number(), b: s.number() }),
};
const world = ecs.createWorld(registry, { resources: {}, events });
// One system fires the event...
scheduler.addSystem((world) => {
world.emit("Collision", { a: entityA, b: entityB });
});
// ...another system reacts to it
scheduler.addSystem((world) => {
for (const event of world.read("Collision")) {
console.log("collision between", event.a, event.b);
}
});Events pile up during a frame and get cleared at the start of the next one.
Entity Hierarchy
Set up parent-child relationships. When a parent is destroyed, its children go with it:
const hierarchy = new ecs.Hierarchy(world);
const parent = world.createEntity();
const child = world.createEntity();
hierarchy.setParent(child, parent);
hierarchy.getParent(child); // parent
hierarchy.getChildren(parent); // [child]
hierarchy.isDescendantOf(child, parent); // true
world.destroyEntity(parent); // child is destroyed tooBatch Spawning
Need a hundred entities with similar components? spawn handles it in one call. Pass a function for per-entity values, or a plain object to share the same value:
const entities = world.spawn(100, {
Position: (i) => ({ x: i * 10, y: 0 }), // unique per entity
Velocity: { x: 1, y: 0 }, // shared by all
});Debug Stats
Peek at what's going on in your world:
world.entityCount; // alive entities
world.componentCount("Position"); // how many have Position
world.stats(); // full breakdown
world.clear(); // wipe everything and start overRuntime/JSON Bridge
Save and load worlds as JSON:
// Load from JSON
const { world: runtimeWorld, issues } = ecs.worldFromJson(registry, worldJson, {
allowUnknownComponents: true,
});
// Save to JSON
const serialized = ecs.worldToJson(registry, runtimeWorld, {
stripUnknownComponents: false,
});